Merge branch 'develop' of https://codeberg.org/calckey/calckey into logged-out
This commit is contained in:
commit
af38bc540d
91 changed files with 2281 additions and 1820 deletions
|
@ -8,7 +8,6 @@
|
|||
- Rewrite backend in Rust and [Rocket](https://rocket.rs/)
|
||||
- Use [Magic RegExP](https://regexp.dev/) for RegEx 🦄
|
||||
- Function
|
||||
- Federate with note edits
|
||||
- User "choices" (recommended users) like Mastodon and Soapbox
|
||||
- Join Reason system like Mastodon/Pleroma
|
||||
- Option to publicize server blocks
|
||||
|
@ -124,6 +123,8 @@
|
|||
- Improve system emails
|
||||
- Mod mail
|
||||
- Focus trapping and button labels
|
||||
- Meilisearch with filters
|
||||
- Post editing
|
||||
|
||||
## Implemented (remote)
|
||||
|
||||
|
|
|
@ -27,6 +27,8 @@
|
|||
- Read **[this document](./CALCKEY.md)** all for current and future differences.
|
||||
- Notable differences:
|
||||
- Improved UI/UX (especially on mobile)
|
||||
- Post editing
|
||||
- Content importing
|
||||
- Improved notifications
|
||||
- Improved server security
|
||||
- Improved accessibility
|
||||
|
@ -37,7 +39,7 @@
|
|||
- Better intro tutorial
|
||||
- Compatibility with Mastodon clients/apps
|
||||
- Backfill user information
|
||||
- Sonic search
|
||||
- Advanced search
|
||||
- Many more user and admin settings
|
||||
- [So much more!](./CALCKEY.md)
|
||||
|
||||
|
|
|
@ -40,26 +40,6 @@ body:
|
|||
placeholder: stop.voring.me
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browser are you using?
|
||||
multiple: false
|
||||
options:
|
||||
- Firefox
|
||||
- Chrome
|
||||
- Brave
|
||||
- Librewolf
|
||||
- Chromium
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
- Other (Please Specify)
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. You can find your log by inspecting the page, and going to the "console" tab. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
|
|
@ -1049,8 +1049,8 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين."
|
||||
registerDevice: "سجّل جهازًا جديدًا"
|
||||
registerKey: "تسجيل مفتاح أمان جديد"
|
||||
registerTOTP: "سجّل جهازًا جديدًا"
|
||||
registerSecurityKey: "تسجيل مفتاح أمان جديد"
|
||||
step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})."
|
||||
step2: "امسح رمز الاستجابة السريعة الموجد على الشاشة."
|
||||
step3: "أدخل الرمز الموجود في تطبيقك لإكمال التثبيت."
|
||||
|
|
|
@ -1130,8 +1130,8 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "আপনি ইতিমধ্যে একটি 2-ফ্যাক্টর অথেনটিকেশন ডিভাইস নিবন্ধন করেছেন৷"
|
||||
registerDevice: "নতুন ডিভাইস নিবন্ধন করুন"
|
||||
registerKey: "সিকিউরিটি কী নিবন্ধন করুন"
|
||||
registerTOTP: "নতুন ডিভাইস নিবন্ধন করুন"
|
||||
registerSecurityKey: "সিকিউরিটি কী নিবন্ধন করুন"
|
||||
step1: "প্রথমে, আপনার ডিভাইসে {a} বা {b} এর মতো একটি অথেনটিকেশন অ্যাপ ইনস্টল করুন৷"
|
||||
step2: "এরপরে, অ্যাপের সাহায্যে প্রদর্শিত QR কোডটি স্ক্যান করুন।"
|
||||
step2Url: "ডেস্কটপ অ্যাপে, নিম্নলিখিত URL লিখুন:"
|
||||
|
|
|
@ -319,17 +319,36 @@ _sfx:
|
|||
_2fa:
|
||||
step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:"
|
||||
alreadyRegistered: Ja heu registrat un dispositiu d'autenticació de dos factors.
|
||||
registerDevice: Registrar un dispositiu nou
|
||||
registerTOTP: Registrar un dispositiu nou
|
||||
securityKeyInfo: A més de l'autenticació d'empremta digital o PIN, també podeu configurar
|
||||
l'autenticació mitjançant claus de seguretat de maquinari compatibles amb FIDO2
|
||||
per protegir encara més el vostre compte.
|
||||
step4: A partir d'ara, qualsevol intent d'inici de sessió futur demanarà aquest
|
||||
token d'inici de sessió.
|
||||
registerKey: Registra una clau de seguretat
|
||||
registerSecurityKey: Registrar una clau de seguretat o d'accés
|
||||
step1: En primer lloc, instal·la una aplicació d'autenticació (com ara {a} o {b})
|
||||
al dispositiu.
|
||||
step2: A continuació, escaneja el codi QR que es mostra en aquesta pantalla.
|
||||
step3: Introdueix el token que t'ha proporcionat l'aplicació per finalitzar la configuració.
|
||||
step3Title: Introduïu un codi d'autenticació
|
||||
chromePasskeyNotSupported: Les claus de pas de Chrome actualment no s'admeten.
|
||||
securityKeyName: Introduïu un nom de clau
|
||||
removeKey: Suprimeix la clau de seguretat
|
||||
removeKeyConfirm: Vols suprimir la clau {name}?
|
||||
renewTOTP: Tornar a configurar l'aplicació d'autenticació
|
||||
renewTOTPOk: Reconfigurar
|
||||
renewTOTPCancel: Cancel·lar
|
||||
step2Click: Fer clic en aquest codi QR us permetrà registrar 2FA a la vostra clau
|
||||
de seguretat o aplicació d'autenticació del telèfon.
|
||||
securityKeyNotSupported: El vostre navegador no admet claus de seguretat.
|
||||
registerTOTPBeforeKey: Configureu una aplicació d'autenticació per registrar una
|
||||
clau de seguretat o de passi.
|
||||
tapSecurityKey: Si us plau, seguiu el vostre navegador per registrar la clau de
|
||||
seguretat o d'accés
|
||||
renewTOTPConfirm: Això farà que els codis de verificació de l'aplicació anterior
|
||||
deixin de funcionar
|
||||
whyTOTPOnlyRenew: L’aplicació d’autenticació no es pot eliminar sempre que es hi
|
||||
hagi una clau de seguretat registrada.
|
||||
_widgets:
|
||||
notifications: "Notificacions"
|
||||
timeline: "Línia de temps"
|
||||
|
@ -988,7 +1007,7 @@ avoidMultiCaptchaConfirm: Fent servir diferents sistemes de Captcha pot causar i
|
|||
antennas: Antenes
|
||||
enableEmojiReactions: Activa reaccions amb emojis
|
||||
blockThisInstance: Bloqueja aquest servidor
|
||||
registration: Registre
|
||||
registration: Registra't
|
||||
showEmojisInReactionNotifications: Mostra els emojis a les notificacions de les reaccions
|
||||
renoteMute: Silencia els impulsos
|
||||
renoteUnmute: Treu el silenci als impulsos
|
||||
|
@ -1035,7 +1054,7 @@ recentlyRegisteredUsers: Usuaris registrats fa poc
|
|||
recentlyDiscoveredUsers: Nous suaris descoberts
|
||||
administrator: Administrador
|
||||
token: Token
|
||||
registerSecurityKey: Registra una clau de seguretat
|
||||
registerSecurityKey: Registreu una clau de seguretat
|
||||
securityKeyName: Nom clau
|
||||
lastUsed: Feta servir per última vegada
|
||||
unregister: Anul·lar el registre
|
||||
|
@ -2107,3 +2126,7 @@ _filters:
|
|||
image: Imatge
|
||||
video: Vídeo
|
||||
audio: Àudio
|
||||
_dialog:
|
||||
charactersExceeded: "S'han superat el màxim de caràcters! Actual: {current}/Límit:
|
||||
{max}"
|
||||
charactersBelow: 'No hi ha caràcters suficients! Corrent: {current}/Limit: {min}'
|
||||
|
|
|
@ -698,8 +698,8 @@ _time:
|
|||
minute: "Minut"
|
||||
hour: "Hodin"
|
||||
_2fa:
|
||||
registerDevice: "Přidat zařízení"
|
||||
registerKey: "Přidat bezpečnostní klíč"
|
||||
registerTOTP: "Přidat zařízení"
|
||||
registerSecurityKey: "Přidat bezpečnostní klíč"
|
||||
_weekday:
|
||||
sunday: "Neděle"
|
||||
monday: "Pondělí"
|
||||
|
|
|
@ -1371,8 +1371,8 @@ _tutorial:
|
|||
_2fa:
|
||||
alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung
|
||||
registriert."
|
||||
registerDevice: "Neues Gerät registrieren"
|
||||
registerKey: "Neuen Sicherheitsschlüssel registrieren"
|
||||
registerTOTP: "Neues Gerät registrieren"
|
||||
registerSecurityKey: "Neuen Sicherheitsschlüssel registrieren"
|
||||
step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem
|
||||
Gerät."
|
||||
step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät."
|
||||
|
@ -1468,9 +1468,13 @@ _widgets:
|
|||
aichan: "Ai"
|
||||
_userList:
|
||||
chooseList: Wählen Sie eine Liste aus
|
||||
userList: Nutzerliste
|
||||
userList: Benutzerliste
|
||||
serverInfo: Server-Infos
|
||||
meiliStatus: Server-Status
|
||||
meiliSize: Indexgröße
|
||||
meiliIndexCount: Indexierte Beiträge
|
||||
_cw:
|
||||
hide: "Inhalt verbergen"
|
||||
hide: "Verbergen"
|
||||
show: "Inhalt anzeigen"
|
||||
chars: "{count} Zeichen"
|
||||
files: "{count} Datei(en)"
|
||||
|
@ -1929,10 +1933,11 @@ _deck:
|
|||
widgets: "Widgets"
|
||||
notifications: "Benachrichtigungen"
|
||||
tl: "Timeline"
|
||||
antenna: "News-Picker"
|
||||
antenna: "Antenne"
|
||||
list: "Listen"
|
||||
mentions: "Erwähnungen"
|
||||
direct: "Direktnachrichten"
|
||||
channel: Kanal
|
||||
renameProfile: Arbeitsbereich umbenennen
|
||||
nameAlreadyExists: Der Name für den Arbeitsbereich ist bereits vorhanden.
|
||||
enableRecommendedTimeline: '"Favoriten"-Timeline einschalten'
|
||||
|
@ -2073,11 +2078,12 @@ _experiments:
|
|||
title: Funktionstests
|
||||
postEditingCaption: Zeigt die Option für Nutzer an, ihre bestehenden Beiträge über
|
||||
das Menü "Beitragsoptionen" zu bearbeiten
|
||||
enablePostImports: Beitragsimporte aktivieren
|
||||
noGraze: Bitte deaktivieren Sie die Browsererweiterung "Graze for Mastodon", da sie
|
||||
die Funktion von Calckey stört.
|
||||
indexFrom: Indexieren ab Beitragskennung aufwärts
|
||||
indexNotice: Wird indexiert. Dies kann einige Zeit dauern. Bitte den Server für mindestens
|
||||
eine Stunde nicht neu starten.
|
||||
indexNotice: Wird jetzt indexiert. Dies wird wahrscheinlich eine Weile dauern, bitte
|
||||
starten Sie Ihren Server für mindestens eine Stunde nicht neu.
|
||||
customKaTeXMacroDescription: "Richten Sie Makros ein, um mathematische Ausdrücke einfach
|
||||
zu schreiben! Die Notation entspricht den LaTeX-Befehlsdefinitionen und wird als\n
|
||||
\\newcommand{\\name}{content} or \\newcommand{\\name}[number of arguments]{content}\n
|
||||
|
@ -2097,3 +2103,12 @@ image: Bild
|
|||
video: Video
|
||||
audio: Audio
|
||||
indexFromDescription: Leer lassen, um jeden Beitrag zu indexieren
|
||||
_filters:
|
||||
fromUser: Von Benutzer
|
||||
notesAfter: Beiträge nach
|
||||
withFile: Mit Datei
|
||||
fromDomain: Von Domain
|
||||
notesBefore: Beiträge vor
|
||||
isBot: Dieses Konto ist ein Bot
|
||||
isModerator: Moderator
|
||||
isAdmin: Administrator
|
||||
|
|
|
@ -126,6 +126,7 @@ clickToShow: "Click to show"
|
|||
sensitive: "NSFW"
|
||||
add: "Add"
|
||||
reaction: "Reactions"
|
||||
removeReaction: "Remove your reaction"
|
||||
enableEmojiReactions: "Enable emoji reactions"
|
||||
showEmojisInReactionNotifications: "Show emojis in reaction notifications"
|
||||
reactionSetting: "Reactions to show in the reaction picker"
|
||||
|
@ -1491,16 +1492,28 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "You have already registered a 2-factor authentication device."
|
||||
registerDevice: "Register a new device"
|
||||
registerKey: "Register a security key"
|
||||
registerTOTP: "Register authenticator app"
|
||||
step1: "First, install an authentication app (such as {a} or {b}) on your device."
|
||||
step2: "Then, scan the QR code displayed on this screen."
|
||||
step2Click: "Clicking on this QR code will allow you to register 2FA to your security key or phone authenticator app."
|
||||
step2Url: "You can also enter this URL if you're using a desktop program:"
|
||||
step3Title: "Enter an authentication code"
|
||||
step3: "Enter the token provided by your app to finish setup."
|
||||
step4: "From now on, any future login attempts will ask for such a login token."
|
||||
securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup
|
||||
authentication via hardware security keys that support FIDO2 to further secure
|
||||
your account."
|
||||
securityKeyNotSupported: "Your browser does not support security keys."
|
||||
registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key."
|
||||
securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup authentication via hardware security keys that support FIDO2 to further secure your account."
|
||||
chromePasskeyNotSupported: "Chrome passkeys are currently not supported."
|
||||
registerSecurityKey: "Register a security or pass key"
|
||||
securityKeyName: "Enter a key name"
|
||||
tapSecurityKey: "Please follow your browser to register the security or pass key"
|
||||
removeKey: "Remove security key"
|
||||
removeKeyConfirm: "Really delete the {name} key?"
|
||||
whyTOTPOnlyRenew: "The authenticator app cannot be removed as long as a security key is registered."
|
||||
renewTOTP: "Reconfigure authenticator app"
|
||||
renewTOTPConfirm: "This will cause verification codes from your previous app to stop working"
|
||||
renewTOTPOk: "Reconfigure"
|
||||
renewTOTPCancel: "Cancel"
|
||||
_permissions:
|
||||
"read:account": "View your account information"
|
||||
"write:account": "Edit your account information"
|
||||
|
@ -2062,3 +2075,7 @@ _experiments:
|
|||
postImportsCaption: "Allows users to import their posts from past Calckey,\
|
||||
\ Misskey, Mastodon, Akkoma, and Pleroma accounts. It may cause slowdowns during\
|
||||
\ load if your queue is bottlenecked."
|
||||
|
||||
_dialog:
|
||||
charactersExceeded: "Max characters exceeded! Current: {current}/Limit: {max}"
|
||||
charactersBelow: "Not enough characters! Current: {current}/Limit: {min}"
|
||||
|
|
|
@ -1331,8 +1331,8 @@ _tutorial:
|
|||
step6_4: "¡Ahora ve, explora y diviértete!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Ya has completado la configuración."
|
||||
registerDevice: "Registrar dispositivo"
|
||||
registerKey: "Registrar clave"
|
||||
registerTOTP: "Registrar dispositivo"
|
||||
registerSecurityKey: "Registrar clave"
|
||||
step1: "Primero, instale en su dispositivo la aplicación de autenticación {a} o\
|
||||
\ {b} u otra."
|
||||
step2: "Luego, escanee con la aplicación el código QR mostrado en pantalla."
|
||||
|
|
|
@ -1262,8 +1262,8 @@ _tutorial:
|
|||
step6_4: "Maintenant, allez-y, explorez et amusez-vous !"
|
||||
_2fa:
|
||||
alreadyRegistered: "Configuration déjà achevée."
|
||||
registerDevice: "Ajouter un nouvel appareil"
|
||||
registerKey: "Enregistrer une clef"
|
||||
registerTOTP: "Ajouter un nouvel appareil"
|
||||
registerSecurityKey: "Enregistrer une clef"
|
||||
step1: "Tout d'abord, installez une application d'authentification, telle que {a}\
|
||||
\ ou {b}, sur votre appareil."
|
||||
step2: "Ensuite, scannez le code QR affiché sur l’écran."
|
||||
|
|
|
@ -1254,8 +1254,8 @@ _tutorial:
|
|||
step7_3: "Semoga berhasil dan bersenang-senanglah! \U0001F680"
|
||||
_2fa:
|
||||
alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor."
|
||||
registerDevice: "Daftarkan perangkat baru"
|
||||
registerKey: "Daftarkan kunci keamanan baru"
|
||||
registerTOTP: "Daftarkan perangkat baru"
|
||||
registerSecurityKey: "Daftarkan kunci keamanan baru"
|
||||
step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat\
|
||||
\ kamu."
|
||||
step2: "Lalu, pindai kode QR yang ada di layar."
|
||||
|
|
113
locales/index.js
113
locales/index.js
|
@ -2,59 +2,90 @@
|
|||
* Languages Loader
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
let languages = []
|
||||
let languages_custom = []
|
||||
|
||||
const merge = (...args) => args.reduce((a, c) => ({
|
||||
...a,
|
||||
...c,
|
||||
...Object.entries(a)
|
||||
.filter(([k]) => c && typeof c[k] === 'object')
|
||||
.reduce((a, [k, v]) => (a[k] = merge(v, c[k]), a), {})
|
||||
}), {});
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const languages = [];
|
||||
const languages_custom = [];
|
||||
|
||||
const merge = (...args) =>
|
||||
args.reduce(
|
||||
(a, c) => ({
|
||||
...a,
|
||||
...c,
|
||||
...Object.entries(a)
|
||||
.filter(([k]) => c && typeof c[k] === "object")
|
||||
.reduce((a, [k, v]) => ((a[k] = merge(v, c[k])), a), {}),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
fs.readdirSync(__dirname).forEach((file) => {
|
||||
if (file.includes('.yml')){
|
||||
file = file.slice(0, file.indexOf('.'))
|
||||
if (file.includes(".yml")) {
|
||||
file = file.slice(0, file.indexOf("."));
|
||||
languages.push(file);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
fs.readdirSync(__dirname + '/../custom/locales').forEach((file) => {
|
||||
if (file.includes('.yml')){
|
||||
file = file.slice(0, file.indexOf('.'))
|
||||
fs.readdirSync(__dirname + "/../custom/locales").forEach((file) => {
|
||||
if (file.includes(".yml")) {
|
||||
file = file.slice(0, file.indexOf("."));
|
||||
languages_custom.push(file);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const primaries = {
|
||||
'en': 'US',
|
||||
'ja': 'JP',
|
||||
'zh': 'CN',
|
||||
en: "US",
|
||||
ja: "JP",
|
||||
zh: "CN",
|
||||
};
|
||||
|
||||
// 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く
|
||||
const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), '');
|
||||
const clean = (text) =>
|
||||
text.replace(new RegExp(String.fromCodePoint(0x08), "g"), "");
|
||||
|
||||
const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(`${__dirname}/${c}.yml`, 'utf-8'))) || {}, a), {});
|
||||
const locales_custom = languages_custom.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(`${__dirname}/../custom/locales/${c}.yml`, 'utf-8'))) || {}, a), {});
|
||||
Object.assign(locales, locales_custom)
|
||||
const locales = languages.reduce(
|
||||
(a, c) => (
|
||||
(a[c] =
|
||||
yaml.load(clean(fs.readFileSync(`${__dirname}/${c}.yml`, "utf-8"))) ||
|
||||
{}),
|
||||
a
|
||||
),
|
||||
{},
|
||||
);
|
||||
const locales_custom = languages_custom.reduce(
|
||||
(a, c) => (
|
||||
(a[c] =
|
||||
yaml.load(
|
||||
clean(
|
||||
fs.readFileSync(`${__dirname}/../custom/locales/${c}.yml`, "utf-8"),
|
||||
),
|
||||
) || {}),
|
||||
a
|
||||
),
|
||||
{},
|
||||
);
|
||||
Object.assign(locales, locales_custom);
|
||||
|
||||
module.exports = Object.entries(locales)
|
||||
.reduce((a, [k ,v]) => (a[k] = (() => {
|
||||
const [lang] = k.split('-');
|
||||
switch (k) {
|
||||
case 'ja-JP': return v;
|
||||
case 'ja-KS':
|
||||
case 'en-US': return merge(locales['ja-JP'], v);
|
||||
default: return merge(
|
||||
locales['ja-JP'],
|
||||
locales['en-US'],
|
||||
locales[`${lang}-${primaries[lang]}`] || {},
|
||||
v
|
||||
);
|
||||
}
|
||||
})(), a), {});
|
||||
module.exports = Object.entries(locales).reduce(
|
||||
(a, [k, v]) => (
|
||||
(a[k] = (() => {
|
||||
const [lang] = k.split("-");
|
||||
switch (k) {
|
||||
case "ja-JP":
|
||||
return v;
|
||||
case "ja-KS":
|
||||
case "en-US":
|
||||
return merge(locales["ja-JP"], v);
|
||||
default:
|
||||
return merge(
|
||||
locales["ja-JP"],
|
||||
locales["en-US"],
|
||||
locales[`${lang}-${primaries[lang]}`] || {},
|
||||
v,
|
||||
);
|
||||
}
|
||||
})()),
|
||||
a
|
||||
),
|
||||
{},
|
||||
);
|
||||
|
|
|
@ -1139,7 +1139,7 @@ _tutorial:
|
|||
Questo però lo fa! È un po' complicato, ma ci riuscirete in poco tempo"
|
||||
step6_4: "Ora andate, esplorate e divertitevi!"
|
||||
_2fa:
|
||||
registerDevice: "Aggiungi dispositivo"
|
||||
registerTOTP: "Aggiungi dispositivo"
|
||||
_permissions:
|
||||
"read:account": "Visualizzare le informazioni dell'account"
|
||||
"write:account": "Modificare le informazioni dell'account"
|
||||
|
|
|
@ -1314,14 +1314,28 @@ _tutorial:
|
|||
step6_4: "これで完了です。お楽しみください!"
|
||||
_2fa:
|
||||
alreadyRegistered: "既に設定は完了しています。"
|
||||
registerDevice: "デバイスを登録"
|
||||
registerKey: "キーを登録"
|
||||
registerTOTP: "認証アプリの設定を開始"
|
||||
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
||||
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
||||
step2Url: "デスクトップアプリでは次のURLを入力します:"
|
||||
step3: "アプリに表示されているトークンを入力して完了です。"
|
||||
step4: "これからログインするときも、同じようにトークンを入力します。"
|
||||
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。"
|
||||
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
|
||||
step2Url: "デスクトップアプリでは次のURIを入力します:"
|
||||
step3Title: "確認コードを入力"
|
||||
step3: "アプリに表示されている確認コード(トークン)を入力して完了です。"
|
||||
step4: "これからログインするときも、同じように確認コードを入力します。"
|
||||
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
|
||||
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
|
||||
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
|
||||
chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。"
|
||||
registerSecurityKey: "セキュリティキー・パスキーを登録する"
|
||||
securityKeyName: "キーの名前を入力"
|
||||
tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"
|
||||
removeKey: "セキュリティキーを削除"
|
||||
removeKeyConfirm: "{name}を削除しますか?"
|
||||
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
|
||||
renewTOTP: "認証アプリを再設定"
|
||||
renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります"
|
||||
renewTOTPOk: "再設定する"
|
||||
renewTOTPCancel: "やめておく"
|
||||
_permissions:
|
||||
"read:account": "アカウントの情報を見る"
|
||||
"write:account": "アカウントの情報を変更する"
|
||||
|
@ -1878,11 +1892,11 @@ _experiments:
|
|||
postImportsCaption:
|
||||
ユーザーが過去の投稿をCalckey・Misskey・Mastodon・Akkoma・Pleromaからインポートすることを許可します。キューが溜まっているときにインポートするとサーバーに負荷がかかる可能性があります。
|
||||
enablePostImports: 投稿のインポートを有効にする
|
||||
sendModMail: モデレーションノートを送る
|
||||
sendModMail: モデレーション通知を送る
|
||||
deleted: 削除済み
|
||||
editNote: 投稿を編集
|
||||
edited: 編集済み
|
||||
signupsDisabled:
|
||||
signupsDisabled:
|
||||
現在、このサーバーでは新規登録が一般開放されていません。招待コードをお持ちの場合には、以下の欄に入力してください。招待コードをお持ちでない場合にも、新規登録を開放している他のサーバーには入れますよ!
|
||||
findOtherInstance: 他のサーバーを探す
|
||||
newer: 新しい投稿
|
||||
|
@ -1898,3 +1912,22 @@ antennasDesc: "アンテナでは指定した条件に合致する投稿が表
|
|||
expandOnNoteClickDesc: オフの場合、右クリックメニューか日付をクリックすることで開けます。
|
||||
expandOnNoteClick: クリックで投稿の詳細を開く
|
||||
clipsDesc: クリップは分類と共有ができるブックマークです。各投稿のメニューからクリップを作成できます。
|
||||
_dialog:
|
||||
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
||||
charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}"
|
||||
_filters:
|
||||
followersOnly: フォロワーのみ
|
||||
fromUser: ユーザーを指定
|
||||
withFile: 添付ファイルあり
|
||||
fromDomain: ドメインを指定
|
||||
notesBefore: 指定の日付以前
|
||||
notesAfter: 指定の日付以降
|
||||
followingOnly: フォロー中のみ
|
||||
isModerator: モデレーター
|
||||
audio: 音声
|
||||
image: 画像
|
||||
video: 動画
|
||||
isBot: このアカウントはBotです
|
||||
isLocked: このアカウントのフォローは承認制です
|
||||
isAdmin: 管理者
|
||||
isPatron: Calckey 後援者
|
||||
|
|
|
@ -1179,8 +1179,8 @@ _time:
|
|||
day: "일"
|
||||
_2fa:
|
||||
alreadyRegistered: "이미 설정이 완료되었습니다."
|
||||
registerDevice: "디바이스 등록"
|
||||
registerKey: "키를 등록"
|
||||
registerTOTP: "디바이스 등록"
|
||||
registerSecurityKey: "키를 등록"
|
||||
step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다."
|
||||
step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다."
|
||||
step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:"
|
||||
|
|
|
@ -1260,8 +1260,8 @@ _tutorial:
|
|||
step6_4: "A teraz idź, odkrywaj i baw się dobrze!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego."
|
||||
registerDevice: "Zarejestruj nowe urządzenie"
|
||||
registerKey: "Zarejestruj klucz bezpieczeństwa"
|
||||
registerTOTP: "Zarejestruj nowe urządzenie"
|
||||
registerSecurityKey: "Zarejestruj klucz bezpieczeństwa"
|
||||
step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b})
|
||||
na swoim urządzeniu."
|
||||
step2: "Następnie, zeskanuje kod QR z ekranu."
|
||||
|
|
87
locales/pt_BR.yml
Normal file
87
locales/pt_BR.yml
Normal file
|
@ -0,0 +1,87 @@
|
|||
username: Nome de usuário
|
||||
ok: OK
|
||||
_lang_: Inglês
|
||||
headlineMisskey: Uma plataforma de mídia social descentralizada e de código aberto
|
||||
que é gratuita para sempre! 🚀
|
||||
search: Pesquisar
|
||||
gotIt: Entendi!
|
||||
introMisskey: Bem vinde! Calckey é uma plataforma de mídia social descentralizada
|
||||
e de código aberto que é gratuita para sempre! 🚀
|
||||
searchPlaceholder: Pesquise no Calckey
|
||||
notifications: Notificações
|
||||
password: Senha
|
||||
forgotPassword: Esqueci a senha
|
||||
cancel: Cancelar
|
||||
noThankYou: Não, obrigade
|
||||
save: Salvar
|
||||
enterUsername: Insira nome de usuário
|
||||
cw: Aviso de conteúdo
|
||||
driveFileDeleteConfirm: Tem a certeza de que pretende apagar o arquivo "{name}"? O
|
||||
arquivo será removido de todas as mensagens que o contenham como anexo.
|
||||
deleteAndEdit: Deletar e editar
|
||||
import: Importar
|
||||
exportRequested: Você pediu uma exportação. Isso pode demorar um pouco. Será adicionado
|
||||
ao seu Drive quando for completo.
|
||||
note: Postar
|
||||
notes: Postagens
|
||||
deleteAndEditConfirm: Você tem certeza que quer deletar esse post e edita-lo? Você
|
||||
vai perder todas as reações, impulsionamentos e respostas dele.
|
||||
showLess: Fechar
|
||||
importRequested: Você requisitou uma importação. Isso pode demorar um pouco.
|
||||
listsDesc: Listas deixam você criar linhas do tempo com usuários específicos. Elas
|
||||
podem ser acessadas pela página de linhas do tempo.
|
||||
edited: Editado
|
||||
sendMessage: Enviar uma mensagem
|
||||
older: antigo
|
||||
createList: Criar lista
|
||||
loadMore: Carregar mais
|
||||
mentions: Menções
|
||||
importAndExport: Importar/Exportar Dados
|
||||
files: Arquivos
|
||||
lists: Listas
|
||||
manageLists: Gerenciar listas
|
||||
error: Erro
|
||||
somethingHappened: Ocorreu um erro
|
||||
retry: Tentar novamente
|
||||
renotedBy: Impulsionado por {user}
|
||||
noNotes: Nenhum post
|
||||
noNotifications: Nenhuma notificação
|
||||
instance: Servidor
|
||||
settings: Configurações
|
||||
basicSettings: Configurações Básicas
|
||||
otherSettings: Outras Configurações
|
||||
openInWindow: Abrir em janela
|
||||
profile: Perfil
|
||||
noAccountDescription: Esse usuário ainda não escreveu sua bio.
|
||||
login: Entrar
|
||||
loggingIn: Entrando
|
||||
logout: Sair
|
||||
signup: Criar conta
|
||||
uploading: Enviando...
|
||||
users: Usuários
|
||||
addUser: Adicione um usuário
|
||||
addInstance: Adicionar um servidor
|
||||
cantFavorite: Não foi possível adicionar aos marcadores.
|
||||
pin: Fixar no perfil
|
||||
unpin: Desfixar do perfil
|
||||
copyContent: Copiar conteúdos
|
||||
copyLink: Copiar link
|
||||
delete: Deletar
|
||||
deleted: Deletado
|
||||
editNote: Editar anotação
|
||||
addToList: Adicionar a lista
|
||||
copyUsername: Copiar nome de usuário
|
||||
searchUser: Procurar por um usuário
|
||||
reply: Responder
|
||||
jumpToPrevious: Pular para o anterior
|
||||
showMore: Mostrar mais
|
||||
newer: novo
|
||||
youGotNewFollower: seguiu você
|
||||
mention: Mencionar
|
||||
directNotes: Mensagens diretas
|
||||
export: Exportar
|
||||
unfollowConfirm: Você tem certez que deseja para de seguir {name}?
|
||||
noLists: Você não possui nenhuma lista
|
||||
following: Seguindo
|
||||
followers: Seguidores
|
||||
followsYou: Segue você
|
|
@ -1249,8 +1249,8 @@ _tutorial:
|
|||
step6_4: "Теперь идите, изучайте и развлекайтесь!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Двухфакторная аутентификация уже настроена."
|
||||
registerDevice: "Зарегистрируйте ваше устройство"
|
||||
registerKey: "Зарегистрировать ключ"
|
||||
registerTOTP: "Зарегистрируйте ваше устройство"
|
||||
registerSecurityKey: "Зарегистрировать ключ"
|
||||
step1: "Прежде всего, установите на устройство приложение для аутентификации, например,\
|
||||
\ {a} или {b}."
|
||||
step2: "Далее отсканируйте отображаемый QR-код при помощи приложения."
|
||||
|
|
|
@ -1196,8 +1196,8 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Už ste zaregistrovali 2-faktorové autentifikačné zariadenie."
|
||||
registerDevice: "Registrovať nové zariadenie"
|
||||
registerKey: "Registrovať bezpečnostný kľúč"
|
||||
registerTOTP: "Registrovať nové zariadenie"
|
||||
registerSecurityKey: "Registrovať bezpečnostný kľúč"
|
||||
step1: "Najprv si nainštalujte autentifikačnú aplikáciu (napríklad {a} alebo {b}) na svoje zariadenie."
|
||||
step2: "Potom, naskenujte QR kód zobrazený na obrazovke."
|
||||
step2Url: "Do aplikácie zadajte nasledujúcu URL adresu:"
|
||||
|
|
|
@ -959,7 +959,7 @@ _tutorial:
|
|||
step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але цей працює! Це трохи складно, але ви швидко розберетеся"
|
||||
step6_4: "Тепер ідіть, вивчайте і розважайтеся!"
|
||||
_2fa:
|
||||
registerKey: "Зареєструвати новий ключ безпеки"
|
||||
registerSecurityKey: "Зареєструвати новий ключ безпеки"
|
||||
_permissions:
|
||||
"read:account": "Переглядати дані профілю"
|
||||
"write:account": "Змінити дані акаунту"
|
||||
|
|
|
@ -1201,8 +1201,8 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước."
|
||||
registerDevice: "Đăng ký một thiết bị"
|
||||
registerKey: "Đăng ký một mã bảo vệ"
|
||||
registerTOTP: "Đăng ký một thiết bị"
|
||||
registerSecurityKey: "Đăng ký một mã bảo vệ"
|
||||
step1: "Trước tiên, hãy cài đặt một ứng dụng xác minh (chẳng hạn như {a} hoặc {b}) trên thiết bị của bạn."
|
||||
step2: "Sau đó, quét mã QR hiển thị trên màn hình này."
|
||||
step2Url: "Bạn cũng có thể nhập URL này nếu sử dụng một chương trình máy tính:"
|
||||
|
|
|
@ -1210,8 +1210,8 @@ _tutorial:
|
|||
step6_4: "现在去学习并享受乐趣!"
|
||||
_2fa:
|
||||
alreadyRegistered: "此设备已被注册"
|
||||
registerDevice: "注册设备"
|
||||
registerKey: "注册密钥"
|
||||
registerTOTP: "注册设备"
|
||||
registerSecurityKey: "注册密钥"
|
||||
step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。"
|
||||
step2: "然后,扫描屏幕上显示的二维码。"
|
||||
step2Url: "在桌面应用程序中输入以下URL:"
|
||||
|
|
|
@ -1219,8 +1219,8 @@ _tutorial:
|
|||
step6_4: "現在開始探索吧!"
|
||||
_2fa:
|
||||
alreadyRegistered: "你已註冊過一個雙重認證的裝置。"
|
||||
registerDevice: "註冊裝置"
|
||||
registerKey: "註冊鍵"
|
||||
registerTOTP: "註冊裝置"
|
||||
registerSecurityKey: "註冊鍵"
|
||||
step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。"
|
||||
step2: "然後,掃描螢幕上的QR code。"
|
||||
step2Url: "在桌面版應用中,請輸入以下的URL:"
|
||||
|
@ -1840,3 +1840,9 @@ subscribePushNotification: 啟用推送通知
|
|||
unsubscribePushNotification: 禁用推送通知
|
||||
pushNotificationAlreadySubscribed: 推送通知已經啟用
|
||||
recommendedInstancesDescription: 以每行分隔的推薦服務器出現在推薦的時間軸中。 不要添加 `https://`,只添加域名。
|
||||
searchPlaceholder: 搜尋 Calckey
|
||||
cw: 內容警告
|
||||
selectChannel: 選擇一個頻道
|
||||
newer: 較新
|
||||
older: 較舊
|
||||
jumpToPrevious: 跳到上一個
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "calckey",
|
||||
"version": "14.0.0-dev46",
|
||||
"version": "14.0.0-dev51",
|
||||
"codename": "aqua",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/calckey/calckey.git"
|
||||
},
|
||||
"packageManager": "pnpm@8.6.1",
|
||||
"packageManager": "pnpm@8.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp",
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
export class RemoveShowTimelineReplies1684206886988 {
|
||||
name = "RemoveShowTimelineReplies1684206886988";
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" DROP COLUMN "showTimelineReplies"`,
|
||||
);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" ADD "showTimelineReplies" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,3 +1,17 @@
|
|||
# Making migrations
|
||||
|
||||
For more information, please read https://www.sea-ql.org/SeaORM/docs/migration/setting-up-migration/
|
||||
|
||||
- Install `sea-orm-cli`
|
||||
```sh
|
||||
cargo install sea-orm-cli
|
||||
```
|
||||
|
||||
- Generate
|
||||
```sh
|
||||
sea-orm-cli migrate generate ****
|
||||
```
|
||||
|
||||
# Running Migrator CLI
|
||||
|
||||
- Generate a new migration file
|
||||
|
|
|
@ -63,8 +63,6 @@ pub struct Model {
|
|||
pub hide_online_status: bool,
|
||||
#[sea_orm(column_name = "isDeleted")]
|
||||
pub is_deleted: bool,
|
||||
#[sea_orm(column_name = "showTimelineReplies")]
|
||||
pub show_timeline_replies: bool,
|
||||
#[sea_orm(column_name = "driveCapacityOverrideMb")]
|
||||
pub drive_capacity_override_mb: Option<i32>,
|
||||
#[sea_orm(column_name = "movedToUri")]
|
||||
|
|
|
@ -101,6 +101,7 @@
|
|||
"nsfwjs": "2.4.2",
|
||||
"oauth": "^0.10.0",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "^9.1.2",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.11.0",
|
||||
"private-ip": "2.3.4",
|
||||
|
@ -123,7 +124,6 @@
|
|||
"semver": "7.5.1",
|
||||
"sharp": "0.32.1",
|
||||
"sonic-channel": "^1.3.1",
|
||||
"speakeasy": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "2.7.0",
|
||||
"syslog-pro": "1.0.0",
|
||||
|
@ -181,7 +181,6 @@
|
|||
"@types/semver": "7.5.0",
|
||||
"@types/sharp": "0.31.1",
|
||||
"@types/sinonjs__fake-timers": "8.1.2",
|
||||
"@types/speakeasy": "2.0.7",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@types/uuid": "8.3.4",
|
||||
|
|
|
@ -5,8 +5,6 @@ import config from "@/config/index.js";
|
|||
|
||||
const logger = dbLogger.createSubLogger("sonic", "gray", false);
|
||||
|
||||
logger.info("Connecting to Sonic");
|
||||
|
||||
const handlers = (type: string): SonicChannel.Handlers => ({
|
||||
connected: () => {
|
||||
logger.succ(`Connected to Sonic ${type}`);
|
||||
|
@ -28,6 +26,10 @@ const handlers = (type: string): SonicChannel.Handlers => ({
|
|||
const hasConfig =
|
||||
config.sonic && (config.sonic.host || config.sonic.port || config.sonic.auth);
|
||||
|
||||
if (hasConfig) {
|
||||
logger.info("Connecting to Sonic");
|
||||
}
|
||||
|
||||
const host = hasConfig ? config.sonic.host ?? "localhost" : "";
|
||||
const port = hasConfig ? config.sonic.port ?? 1491 : 0;
|
||||
const auth = hasConfig ? config.sonic.auth ?? "SecretPassword" : "";
|
||||
|
|
|
@ -249,12 +249,6 @@ export class User {
|
|||
})
|
||||
public followersUri: string | null;
|
||||
|
||||
@Column("boolean", {
|
||||
default: false,
|
||||
comment: "Whether to show users replying to other users in the timeline.",
|
||||
})
|
||||
public showTimelineReplies: boolean;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column("char", {
|
||||
length: 16,
|
||||
|
|
|
@ -567,7 +567,6 @@ export const UserRepository = db.getRepository(User).extend({
|
|||
mutedInstances: profile!.mutedInstances,
|
||||
mutingNotificationTypes: profile!.mutingNotificationTypes,
|
||||
emailNotificationTypes: profile!.emailNotificationTypes,
|
||||
showTimelineReplies: user.showTimelineReplies || falsy,
|
||||
}
|
||||
: {}),
|
||||
|
||||
|
|
|
@ -17,9 +17,7 @@ export async function deleteAccount(
|
|||
logger.info(`Deleting account of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await Users.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
if (!user) return;
|
||||
|
||||
{
|
||||
// Delete notes
|
||||
|
|
|
@ -15,9 +15,11 @@ export async function deleteActor(
|
|||
return `skip: delete actor ${actor.uri} !== ${uri}`;
|
||||
}
|
||||
|
||||
const user = await Users.findOneByOrFail({ id: actor.id });
|
||||
if (user.isDeleted) {
|
||||
logger.info("skip: already deleted");
|
||||
const user = await Users.findOneBy({ id: actor.id });
|
||||
if (!user) {
|
||||
return `skip: actor ${actor.id} not found in the local database`;
|
||||
} else if (user.isDeleted) {
|
||||
return `skip: user ${user.id} already deleted`;
|
||||
}
|
||||
|
||||
const job = await createDeleteAccountJob(actor);
|
||||
|
|
|
@ -279,7 +279,6 @@ export async function createPerson(
|
|||
tags,
|
||||
isBot,
|
||||
isCat: (person as any).isCat === true,
|
||||
showTimelineReplies: false,
|
||||
}),
|
||||
)) as IRemoteUser;
|
||||
|
||||
|
|
|
@ -4,7 +4,8 @@ import { Brackets } from "typeorm";
|
|||
|
||||
export function generateRepliesQuery(
|
||||
q: SelectQueryBuilder<any>,
|
||||
me?: Pick<User, "id" | "showTimelineReplies"> | null,
|
||||
withReplies: boolean,
|
||||
me?: Pick<User, "id"> | null,
|
||||
) {
|
||||
if (me == null) {
|
||||
q.andWhere(
|
||||
|
@ -20,25 +21,21 @@ export function generateRepliesQuery(
|
|||
);
|
||||
}),
|
||||
);
|
||||
} else if (!me.showTimelineReplies) {
|
||||
} else if (!withReplies) {
|
||||
q.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where("note.replyId IS NULL") // 返信ではない
|
||||
.orWhere("note.replyUserId = :meId", { meId: me.id }) // 返信だけど自分のノートへの返信
|
||||
.orWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where(
|
||||
// 返信だけど自分の行った返信
|
||||
"note.replyId IS NOT NULL",
|
||||
).andWhere("note.userId = :meId", { meId: me.id });
|
||||
qb.where("note.replyId IS NOT NULL") // 返信だけど自分の行った返信
|
||||
.andWhere("note.userId = :meId", { meId: me.id });
|
||||
}),
|
||||
)
|
||||
.orWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where(
|
||||
// 返信だけど投稿者自身への返信
|
||||
"note.replyId IS NOT NULL",
|
||||
).andWhere("note.replyUserId = note.userId");
|
||||
qb.where("note.replyId IS NOT NULL") // 返信だけど投稿者自身への返信
|
||||
.andWhere("note.replyUserId = note.userId");
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
|
|
@ -174,6 +174,7 @@ import * as ep___i_2fa_keyDone from "./endpoints/i/2fa/key-done.js";
|
|||
import * as ep___i_2fa_passwordLess from "./endpoints/i/2fa/password-less.js";
|
||||
import * as ep___i_2fa_registerKey from "./endpoints/i/2fa/register-key.js";
|
||||
import * as ep___i_2fa_register from "./endpoints/i/2fa/register.js";
|
||||
import * as ep___i_2fa_updateKey from "./endpoints/i/2fa/update-key.js";
|
||||
import * as ep___i_2fa_removeKey from "./endpoints/i/2fa/remove-key.js";
|
||||
import * as ep___i_2fa_unregister from "./endpoints/i/2fa/unregister.js";
|
||||
import * as ep___i_apps from "./endpoints/i/apps.js";
|
||||
|
@ -528,6 +529,7 @@ const eps = [
|
|||
["i/2fa/password-less", ep___i_2fa_passwordLess],
|
||||
["i/2fa/register-key", ep___i_2fa_registerKey],
|
||||
["i/2fa/register", ep___i_2fa_register],
|
||||
["i/2fa/update-key", ep___i_2fa_updateKey],
|
||||
["i/2fa/remove-key", ep___i_2fa_removeKey],
|
||||
["i/2fa/unregister", ep___i_2fa_unregister],
|
||||
["i/apps", ep___i_apps],
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import * as speakeasy from "speakeasy";
|
||||
import { publishMainStream } from "@/services/stream.js";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import define from "../../../define.js";
|
||||
import { UserProfiles } from "@/models/index.js";
|
||||
import { Users, UserProfiles } from "@/models/index.js";
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
@ -25,13 +26,14 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
throw new Error("二段階認証の設定が開始されていません");
|
||||
}
|
||||
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorTempSecret,
|
||||
encoding: "base32",
|
||||
token: token,
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
if (delta === null) {
|
||||
throw new Error("not verified");
|
||||
}
|
||||
|
||||
|
@ -39,4 +41,11 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
twoFactorSecret: profile.twoFactorTempSecret,
|
||||
twoFactorEnabled: true,
|
||||
});
|
||||
|
||||
const iObj = await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
publishMainStream(user.id, "meUpdated", iObj);
|
||||
});
|
||||
|
|
|
@ -28,7 +28,7 @@ export const paramDef = {
|
|||
attestationObject: { type: "string" },
|
||||
password: { type: "string" },
|
||||
challengeId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
name: { type: "string", minLength: 1, maxLength: 30 },
|
||||
},
|
||||
required: [
|
||||
"clientDataJSON",
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
import define from "../../../define.js";
|
||||
import { UserProfiles } from "@/models/index.js";
|
||||
import { Users, UserProfiles, UserSecurityKeys } from "@/models/index.js";
|
||||
import { publishMainStream } from "@/services/stream.js";
|
||||
import { ApiError } from "../../../error.js";
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
noKey: {
|
||||
message: "No security key.",
|
||||
code: "NO_SECURITY_KEY",
|
||||
id: "f9c54d7f-d4c2-4d3c-9a8g-a70daac86512",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -16,7 +26,36 @@ export const paramDef = {
|
|||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
if (ps.value === true) {
|
||||
// セキュリティキーがなければパスワードレスを有効にはできない
|
||||
const keyCount = await UserSecurityKeys.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
lastUsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (keyCount === 0) {
|
||||
await UserProfiles.update(user.id, {
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
|
||||
throw new ApiError(meta.errors.noKey);
|
||||
}
|
||||
}
|
||||
|
||||
await UserProfiles.update(user.id, {
|
||||
usePasswordLessLogin: ps.value,
|
||||
});
|
||||
|
||||
const iObj = await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
publishMainStream(user.id, "meUpdated", iObj);
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import * as speakeasy from "speakeasy";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import * as QRCode from "qrcode";
|
||||
import config from "@/config/index.js";
|
||||
import { UserProfiles } from "@/models/index.js";
|
||||
|
@ -30,25 +30,24 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
}
|
||||
|
||||
// Generate user's secret key
|
||||
const secret = speakeasy.generateSecret({
|
||||
length: 32,
|
||||
});
|
||||
const secret = new OTPAuth.Secret();
|
||||
|
||||
await UserProfiles.update(user.id, {
|
||||
twoFactorTempSecret: secret.base32,
|
||||
});
|
||||
|
||||
// Get the data URL of the authenticator URL
|
||||
const url = speakeasy.otpauthURL({
|
||||
secret: secret.base32,
|
||||
encoding: "base32",
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret,
|
||||
digits: 6,
|
||||
label: user.username,
|
||||
issuer: config.host,
|
||||
});
|
||||
const dataUrl = await QRCode.toDataURL(url);
|
||||
const url = totp.toString();
|
||||
const qr = await QRCode.toDataURL(url);
|
||||
|
||||
return {
|
||||
qr: dataUrl,
|
||||
qr,
|
||||
url,
|
||||
secret: secret.base32,
|
||||
label: user.username,
|
||||
|
|
|
@ -34,6 +34,24 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
id: ps.credentialId,
|
||||
});
|
||||
|
||||
// 使われているキーがなくなったらパスワードレスログインをやめる
|
||||
const keyCount = await UserSecurityKeys.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
lastUsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (keyCount === 0) {
|
||||
await UserProfiles.update(me.id, {
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Publish meUpdated event
|
||||
publishMainStream(
|
||||
user.id,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { publishMainStream } from "@/services/stream.js";
|
||||
import define from "../../../define.js";
|
||||
import { UserProfiles } from "@/models/index.js";
|
||||
import { Users, UserProfiles } from "@/models/index.js";
|
||||
import { comparePassword } from "@/misc/password.js";
|
||||
|
||||
export const meta = {
|
||||
|
@ -29,5 +30,13 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
await UserProfiles.update(user.id, {
|
||||
twoFactorSecret: null,
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
|
||||
const iObj = await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
publishMainStream(user.id, "meUpdated", iObj);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
import { publishMainStream } from "@/services/stream.js";
|
||||
import define from "../../../define.js";
|
||||
import { Users, UserSecurityKeys } from "@/models/index.js";
|
||||
import { ApiError } from "../../../error.js";
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
noSuchKey: {
|
||||
message: "No such key.",
|
||||
code: "NO_SUCH_KEY",
|
||||
id: "f9c5467f-d492-4d3c-9a8g-a70dacc86512",
|
||||
},
|
||||
|
||||
accessDenied: {
|
||||
message: "You do not have edit privilege of the channel.",
|
||||
code: "ACCESS_DENIED",
|
||||
id: "1fb7cb09-d46a-4fff-b8df-057708cce513",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1, maxLength: 30 },
|
||||
credentialId: { type: "string" },
|
||||
},
|
||||
required: ["name", "credentialId"],
|
||||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const key = await UserSecurityKeys.findOneBy({
|
||||
id: ps.credentialId,
|
||||
});
|
||||
|
||||
if (key == null) {
|
||||
throw new ApiError(meta.errors.noSuchKey);
|
||||
}
|
||||
|
||||
if (key.userId !== user.id) {
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
await UserSecurityKeys.update(key.id, {
|
||||
name: ps.name,
|
||||
});
|
||||
|
||||
const iObj = await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
publishMainStream(user.id, "meUpdated", iObj);
|
||||
});
|
|
@ -106,7 +106,6 @@ export const paramDef = {
|
|||
isBot: { type: "boolean" },
|
||||
isCat: { type: "boolean" },
|
||||
speakAsCat: { type: "boolean" },
|
||||
showTimelineReplies: { type: "boolean" },
|
||||
injectFeaturedNote: { type: "boolean" },
|
||||
receiveAnnouncementEmail: { type: "boolean" },
|
||||
alwaysMarkNsfw: { type: "boolean" },
|
||||
|
@ -185,8 +184,6 @@ export default define(meta, paramDef, async (ps, _user, token) => {
|
|||
if (typeof ps.publicReactions === "boolean")
|
||||
profileUpdates.publicReactions = ps.publicReactions;
|
||||
if (typeof ps.isBot === "boolean") updates.isBot = ps.isBot;
|
||||
if (typeof ps.showTimelineReplies === "boolean")
|
||||
updates.showTimelineReplies = ps.showTimelineReplies;
|
||||
if (typeof ps.carefulBot === "boolean")
|
||||
profileUpdates.carefulBot = ps.carefulBot;
|
||||
if (typeof ps.autoAcceptFollowed === "boolean")
|
||||
|
|
|
@ -53,6 +53,11 @@ export const paramDef = {
|
|||
untilId: { type: "string", format: "misskey:id" },
|
||||
sinceDate: { type: "integer" },
|
||||
untilDate: { type: "integer" },
|
||||
withReplies: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Show replies in the timeline",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -87,7 +92,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
|
||||
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
|
||||
|
||||
generateRepliesQuery(query, user);
|
||||
generateRepliesQuery(query, ps.withReplies, user);
|
||||
if (user) {
|
||||
generateMutedUserQuery(query, user);
|
||||
generateMutedNoteQuery(query, user);
|
||||
|
|
|
@ -60,6 +60,11 @@ export const paramDef = {
|
|||
default: false,
|
||||
description: "Only show notes that have attached files.",
|
||||
},
|
||||
withReplies: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Show replies in the timeline",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -104,7 +109,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
.setParameters(followingQuery.getParameters());
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateRepliesQuery(query, ps.withReplies, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
generateMutedUserQuery(query, user);
|
||||
generateMutedNoteQuery(query, user);
|
||||
|
|
|
@ -63,6 +63,11 @@ export const paramDef = {
|
|||
untilId: { type: "string", format: "misskey:id" },
|
||||
sinceDate: { type: "integer" },
|
||||
untilDate: { type: "integer" },
|
||||
withReplies: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Show replies in the timeline",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -97,7 +102,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateRepliesQuery(query, ps.withReplies, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
if (user) generateMutedUserQuery(query, user);
|
||||
if (user) generateMutedNoteQuery(query, user);
|
||||
|
|
|
@ -63,6 +63,11 @@ export const paramDef = {
|
|||
untilId: { type: "string", format: "misskey:id" },
|
||||
sinceDate: { type: "integer" },
|
||||
untilDate: { type: "integer" },
|
||||
withReplies: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Show replies in the timeline",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -100,7 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateRepliesQuery(query, ps.withReplies, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
if (user) generateMutedUserQuery(query, user);
|
||||
if (user) generateMutedNoteQuery(query, user);
|
||||
|
|
|
@ -54,6 +54,11 @@ export const paramDef = {
|
|||
default: false,
|
||||
description: "Only show notes that have attached files.",
|
||||
},
|
||||
withReplies: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Show replies in the timeline",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -100,7 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
.setParameters(followingQuery.getParameters());
|
||||
|
||||
generateChannelQuery(query, user);
|
||||
generateRepliesQuery(query, user);
|
||||
generateRepliesQuery(query, ps.withReplies, user);
|
||||
generateVisibilityQuery(query, user);
|
||||
generateMutedUserQuery(query, user);
|
||||
generateMutedNoteQuery(query, user);
|
||||
|
|
|
@ -12,7 +12,7 @@ export async function getInstance(response: Entity.Instance) {
|
|||
uri: response.uri,
|
||||
title: response.title || "Calckey",
|
||||
short_description:
|
||||
response.description.substring(0, 50) || "See real server website",
|
||||
response.description?.substring(0, 50) || "See real server website",
|
||||
description:
|
||||
response.description ||
|
||||
"This is a vanilla Calckey Instance. It doesnt seem to have a description. BTW you are using the Mastodon api to access this server :)",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type Koa from "koa";
|
||||
import * as speakeasy from "speakeasy";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import signin from "../common/signin.js";
|
||||
import config from "@/config/index.js";
|
||||
import {
|
||||
|
@ -136,14 +136,18 @@ export default async (ctx: Koa.Context) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorSecret,
|
||||
encoding: "base32",
|
||||
token: token,
|
||||
window: 2,
|
||||
if (profile.twoFactorSecret == null) {
|
||||
throw new Error("Attempted 2FA signin without 2FA enabled.");
|
||||
}
|
||||
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
if (delta != null) {
|
||||
signin(ctx, user);
|
||||
return;
|
||||
} else {
|
||||
|
|
|
@ -9,6 +9,7 @@ export default class extends Channel {
|
|||
public readonly chName = "globalTimeline";
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = false;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(id: string, connection: Channel["connection"]) {
|
||||
super(id, connection);
|
||||
|
@ -22,6 +23,8 @@ export default class extends Channel {
|
|||
return;
|
||||
}
|
||||
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
}
|
||||
|
@ -31,7 +34,7 @@ export default class extends Channel {
|
|||
if (note.channelId != null) return;
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.user!.showTimelineReplies) {
|
||||
if (note.reply && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (
|
||||
|
|
|
@ -8,6 +8,7 @@ export default class extends Channel {
|
|||
public readonly chName = "homeTimeline";
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(id: string, connection: Channel["connection"]) {
|
||||
super(id, connection);
|
||||
|
@ -15,6 +16,8 @@ export default class extends Channel {
|
|||
}
|
||||
|
||||
public async init(params: any) {
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
}
|
||||
|
@ -39,7 +42,7 @@ export default class extends Channel {
|
|||
return;
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.user!.showTimelineReplies) {
|
||||
if (note.reply && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (
|
||||
|
|
|
@ -9,6 +9,7 @@ export default class extends Channel {
|
|||
public readonly chName = "hybridTimeline";
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(id: string, connection: Channel["connection"]) {
|
||||
super(id, connection);
|
||||
|
@ -24,6 +25,8 @@ export default class extends Channel {
|
|||
)
|
||||
return;
|
||||
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
}
|
||||
|
@ -56,7 +59,7 @@ export default class extends Channel {
|
|||
return;
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.user!.showTimelineReplies) {
|
||||
if (note.reply && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (
|
||||
|
|
|
@ -8,6 +8,7 @@ export default class extends Channel {
|
|||
public readonly chName = "localTimeline";
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = false;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(id: string, connection: Channel["connection"]) {
|
||||
super(id, connection);
|
||||
|
@ -21,6 +22,8 @@ export default class extends Channel {
|
|||
return;
|
||||
}
|
||||
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
}
|
||||
|
@ -32,7 +35,7 @@ export default class extends Channel {
|
|||
return;
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.user!.showTimelineReplies) {
|
||||
if (note.reply && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (
|
||||
|
|
|
@ -9,6 +9,7 @@ export default class extends Channel {
|
|||
public readonly chName = "recommendedTimeline";
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true;
|
||||
private withReplies: boolean;
|
||||
|
||||
constructor(id: string, connection: Channel["connection"]) {
|
||||
super(id, connection);
|
||||
|
@ -24,6 +25,8 @@ export default class extends Channel {
|
|||
)
|
||||
return;
|
||||
|
||||
this.withReplies = params.withReplies as boolean;
|
||||
|
||||
// Subscribe events
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
}
|
||||
|
@ -54,7 +57,7 @@ export default class extends Channel {
|
|||
return;
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.user!.showTimelineReplies) {
|
||||
if (note.reply && !this.withReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (
|
||||
|
|
|
@ -56,7 +56,7 @@ import { checkHitAntenna } from "@/misc/check-hit-antenna.js";
|
|||
import { getWordHardMute } from "@/misc/check-word-mute.js";
|
||||
import { addNoteToAntenna } from "../add-note-to-antenna.js";
|
||||
import { countSameRenotes } from "@/misc/count-same-renotes.js";
|
||||
import { deliverToRelays } from "../relay.js";
|
||||
import { deliverToRelays, getCachedRelays } from "../relay.js";
|
||||
import type { Channel } from "@/models/entities/channel.js";
|
||||
import { normalizeForSearch } from "@/misc/normalize-for-search.js";
|
||||
import { getAntennas } from "@/misc/antenna-cache.js";
|
||||
|
@ -68,6 +68,7 @@ import { db } from "@/db/postgre.js";
|
|||
import { getActiveWebhooks } from "@/misc/webhook-cache.js";
|
||||
import { shouldSilenceInstance } from "@/misc/should-block-instance.js";
|
||||
import meilisearch from "../../db/meilisearch.js";
|
||||
import { redisClient } from "@/db/redis.js";
|
||||
|
||||
const mutedWordsCache = new Cache<
|
||||
{ userId: UserProfile["userId"]; mutedWords: UserProfile["mutedWords"] }[]
|
||||
|
@ -165,6 +166,7 @@ export default async (
|
|||
isSilenced: User["isSilenced"];
|
||||
createdAt: User["createdAt"];
|
||||
isBot: User["isBot"];
|
||||
inbox?: User["inbox"];
|
||||
},
|
||||
data: Option,
|
||||
silent = false,
|
||||
|
@ -453,7 +455,37 @@ export default async (
|
|||
}
|
||||
|
||||
if (!dontFederateInitially) {
|
||||
publishNotesStream(note);
|
||||
const relays = await getCachedRelays();
|
||||
// Some relays (e.g., aode-relay) deliver posts by boosting them as
|
||||
// Announce activities. In that case, user is the relay's actor.
|
||||
const boostedByRelay =
|
||||
!!user.inbox &&
|
||||
relays.map((relay) => relay.inbox).includes(user.inbox);
|
||||
|
||||
if (!note.uri) {
|
||||
// Publish if the post is local
|
||||
publishNotesStream(note);
|
||||
} else if (
|
||||
boostedByRelay &&
|
||||
data.renote?.uri &&
|
||||
(await redisClient.exists(`publishedNote:${data.renote.uri}`)) === 0
|
||||
) {
|
||||
// Publish if the post was boosted by a relay and not yet published.
|
||||
publishNotesStream(data.renote);
|
||||
const key = `publishedNote:${data.renote.uri}`;
|
||||
await redisClient.set(key, 1, "EX", 30);
|
||||
} else if (
|
||||
!boostedByRelay &&
|
||||
note.uri &&
|
||||
(await redisClient.exists(`publishedNote:${note.uri}`)) === 0
|
||||
) {
|
||||
// Publish if the post came directly from a remote server, or from a
|
||||
// relay that doesn't boost the post (e.g, YUKIMOCHI Activity-Relay),
|
||||
// and not yet published.
|
||||
const key = `publishedNote:${note.uri}`;
|
||||
publishNotesStream(note);
|
||||
await redisClient.set(key, 1, "EX", 30);
|
||||
}
|
||||
}
|
||||
if (note.replyId != null) {
|
||||
// Only provide the reply note id here as the recipient may not be authorized to see the note.
|
||||
|
@ -524,7 +556,6 @@ export default async (
|
|||
nm.push(data.renote.userId, type);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch watchers
|
||||
nmRelatedPromises.push(
|
||||
notifyToWatchersOfRenotee(data.renote, user, nm, type),
|
||||
|
@ -537,8 +568,9 @@ export default async (
|
|||
});
|
||||
publishMainStream(data.renote.userId, "renote", packedRenote);
|
||||
|
||||
const renote = data.renote;
|
||||
const webhooks = (await getActiveWebhooks()).filter(
|
||||
(x) => x.userId === data.renote!.userId && x.on.includes("renote"),
|
||||
(x) => x.userId === renote.userId && x.on.includes("renote"),
|
||||
);
|
||||
for (const webhook of webhooks) {
|
||||
webhookDeliver(webhook, "renote", {
|
||||
|
|
|
@ -37,7 +37,7 @@ export async function addRelay(inbox: string) {
|
|||
}).then((x) => Relays.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
const relayActor = await getRelayActor();
|
||||
const follow = await renderFollowRelay(relay, relayActor);
|
||||
const follow = renderFollowRelay(relay, relayActor);
|
||||
const activity = renderActivity(follow);
|
||||
deliver(relayActor, activity, relay.inbox);
|
||||
|
||||
|
@ -60,6 +60,7 @@ export async function removeRelay(inbox: string) {
|
|||
deliver(relayActor, activity, relay.inbox);
|
||||
|
||||
await Relays.delete(relay.id);
|
||||
await updateRelaysCache();
|
||||
}
|
||||
|
||||
export async function listRelay() {
|
||||
|
@ -67,14 +68,31 @@ export async function listRelay() {
|
|||
return relays;
|
||||
}
|
||||
|
||||
export async function getCachedRelays(): Promise<Relay[]> {
|
||||
return await relaysCache.fetch(null, () =>
|
||||
Relays.findBy({
|
||||
status: "accepted",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function relayAccepted(id: string) {
|
||||
const result = await Relays.update(id, {
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
await updateRelaysCache();
|
||||
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
async function updateRelaysCache() {
|
||||
const relays = await Relays.findBy({
|
||||
status: "accepted",
|
||||
});
|
||||
relaysCache.set(null, relays);
|
||||
}
|
||||
|
||||
export async function relayRejected(id: string) {
|
||||
const result = await Relays.update(id, {
|
||||
status: "rejected",
|
||||
|
@ -89,11 +107,7 @@ export async function deliverToRelays(
|
|||
) {
|
||||
if (activity == null) return;
|
||||
|
||||
const relays = await relaysCache.fetch(null, () =>
|
||||
Relays.findBy({
|
||||
status: "accepted",
|
||||
}),
|
||||
);
|
||||
const relays = await getCachedRelays();
|
||||
if (relays.length === 0) return;
|
||||
|
||||
// TODO
|
||||
|
|
|
@ -43,13 +43,7 @@ describe("ユーザー", () => {
|
|||
roles: any[];
|
||||
};
|
||||
|
||||
type MeDetailed = UserDetailedNotMe &
|
||||
misskey.entities.MeDetailed & {
|
||||
showTimelineReplies: boolean;
|
||||
achievements: object[];
|
||||
loggedInDays: number;
|
||||
policies: object;
|
||||
};
|
||||
type MeDetailed = UserDetailedNotMe & misskey.entities.MeDetailed;
|
||||
|
||||
type User = MeDetailed & { token: string };
|
||||
|
||||
|
@ -172,9 +166,6 @@ describe("ユーザー", () => {
|
|||
mutedInstances: user.mutedInstances,
|
||||
mutingNotificationTypes: user.mutingNotificationTypes,
|
||||
emailNotificationTypes: user.emailNotificationTypes,
|
||||
showTimelineReplies: user.showTimelineReplies,
|
||||
achievements: user.achievements,
|
||||
loggedInDays: user.loggedInDays,
|
||||
policies: user.policies,
|
||||
...(security
|
||||
? {
|
||||
|
@ -479,13 +470,6 @@ describe("ユーザー", () => {
|
|||
"follow",
|
||||
"receiveFollowRequest",
|
||||
]);
|
||||
assert.strictEqual(response.showTimelineReplies, false);
|
||||
assert.deepStrictEqual(response.achievements, []);
|
||||
assert.deepStrictEqual(response.loggedInDays, 0);
|
||||
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
|
||||
assert.notStrictEqual(response.email, undefined);
|
||||
assert.strictEqual(response.emailVerified, false);
|
||||
assert.deepStrictEqual(response.securityKeysList, []);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
@ -551,8 +535,6 @@ describe("ユーザー", () => {
|
|||
{ parameters: (): object => ({ isBot: false }) },
|
||||
{ parameters: (): object => ({ isCat: true }) },
|
||||
{ parameters: (): object => ({ isCat: false }) },
|
||||
{ parameters: (): object => ({ showTimelineReplies: true }) },
|
||||
{ parameters: (): object => ({ showTimelineReplies: false }) },
|
||||
{ parameters: (): object => ({ injectFeaturedNote: true }) },
|
||||
{ parameters: (): object => ({ injectFeaturedNote: false }) },
|
||||
{ parameters: (): object => ({ receiveAnnouncementEmail: true }) },
|
||||
|
|
|
@ -725,6 +725,7 @@ export type Endpoints = {
|
|||
"i/2fa/password-less": { req: TODO; res: TODO };
|
||||
"i/2fa/register-key": { req: TODO; res: TODO };
|
||||
"i/2fa/register": { req: TODO; res: TODO };
|
||||
"i/2fa/update-key": { req: TODO; res: TODO };
|
||||
"i/2fa/remove-key": { req: TODO; res: TODO };
|
||||
"i/2fa/unregister": { req: TODO; res: TODO };
|
||||
|
||||
|
|
|
@ -87,6 +87,7 @@
|
|||
"vue-isyourpasswordsafe": "^2.0.0",
|
||||
"vue-plyr": "^7.0.0",
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"vue3-otp-input": "^0.4.1",
|
||||
"vuedraggable": "4.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { defineAsyncComponent, reactive } from "vue";
|
||||
import * as misskey from "calckey-js";
|
||||
import { showSuspendedDialog } from "./scripts/show-suspended-dialog";
|
||||
import { i18n } from "./i18n";
|
||||
import { del, get, set } from "@/scripts/idb-proxy";
|
||||
import { apiUrl } from "@/config";
|
||||
|
|
|
@ -53,12 +53,15 @@
|
|||
>
|
||||
<Mfm :text="i18n.ts.password" />
|
||||
</header>
|
||||
<div v-if="text" :class="$style.text"><Mfm :text="text" /></div>
|
||||
<div v-if="text" :class="$style.text">
|
||||
<Mfm :text="text" />
|
||||
</div>
|
||||
<MkInput
|
||||
ref="inputEl"
|
||||
v-if="input && input.type !== 'paragraph'"
|
||||
v-model="inputValue"
|
||||
autofocus
|
||||
:autocomplete="input.autocomplete"
|
||||
:type="input.type == 'search' ? 'search' : input.type || 'text'"
|
||||
:placeholder="input.placeholder || undefined"
|
||||
@keydown="onInputKeydown"
|
||||
|
@ -69,6 +72,22 @@
|
|||
<template v-if="input.type === 'password'" #prefix
|
||||
><i class="ph-password ph-bold ph-lg"></i
|
||||
></template>
|
||||
<template #caption>
|
||||
<span
|
||||
v-if="
|
||||
okButtonDisabled &&
|
||||
disabledReason === 'charactersExceeded'
|
||||
"
|
||||
v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"
|
||||
/>
|
||||
<span
|
||||
v-else-if="
|
||||
okButtonDisabled &&
|
||||
disabledReason === 'charactersBelow'
|
||||
"
|
||||
v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="input.type === 'search'" #suffix>
|
||||
<button
|
||||
class="_buttonIcon"
|
||||
|
@ -118,6 +137,7 @@
|
|||
inline
|
||||
primary
|
||||
:autofocus="!input && !select"
|
||||
:disabled="okButtonDisabled"
|
||||
@click="ok"
|
||||
>{{
|
||||
showCancelButton || input || select
|
||||
|
@ -139,8 +159,8 @@
|
|||
primary
|
||||
:autofocus="!input && !select"
|
||||
@click="ok"
|
||||
>{{ i18n.ts.yes }}</MkButton
|
||||
>
|
||||
>{{ i18n.ts.yes }}
|
||||
</MkButton>
|
||||
<MkButton
|
||||
v-if="showCancelButton || input || select"
|
||||
inline
|
||||
|
@ -182,7 +202,10 @@ import * as Acct from "calckey-js/built/acct";
|
|||
type Input = {
|
||||
type: HTMLInputElement["type"];
|
||||
placeholder?: string | null;
|
||||
default: any | null;
|
||||
autocomplete?: string;
|
||||
default: string | number | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
type Select = {
|
||||
|
@ -245,8 +268,35 @@ const emit = defineEmits<{
|
|||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const inputValue = ref(props.input?.default || "");
|
||||
const selectedValue = ref(props.select?.default || null);
|
||||
const inputValue = ref<string | number | null>(props.input?.default ?? null);
|
||||
const selectedValue = ref(props.select?.default ?? null);
|
||||
|
||||
let disabledReason = $ref<null | "charactersExceeded" | "charactersBelow">(
|
||||
null
|
||||
);
|
||||
const okButtonDisabled = $computed<boolean>(() => {
|
||||
if (props.input) {
|
||||
if (props.input.minLength) {
|
||||
if (
|
||||
(inputValue.value || inputValue.value === "") &&
|
||||
(inputValue.value as string).length < props.input.minLength
|
||||
) {
|
||||
disabledReason = "charactersBelow";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (props.input.maxLength) {
|
||||
if (
|
||||
inputValue.value &&
|
||||
(inputValue.value as string).length > props.input.maxLength
|
||||
) {
|
||||
disabledReason = "charactersExceeded";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const inputEl = ref<typeof MkInput>();
|
||||
|
||||
|
@ -289,8 +339,8 @@ function onInputKeydown(evt: KeyboardEvent) {
|
|||
function formatDateToYYYYMMDD(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = ("0" + (date.getMonth() + 1)).slice(-2);
|
||||
const day = ("0" + date.getDate()).slice(-2);
|
||||
return `${year}${month}${day}`;
|
||||
const day = ("0" + (date.getDate() + 1)).slice(-2);
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
async function openSearchFilters(ev) {
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
<template>
|
||||
<button class="menu _button" @click.stop="menu" v-tooltip="i18n.ts.menu">
|
||||
<button
|
||||
v-if="!hideMenu"
|
||||
class="menu _button"
|
||||
@click.stop="menu"
|
||||
v-tooltip="i18n.ts.menu"
|
||||
>
|
||||
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<button
|
||||
|
@ -15,16 +20,17 @@
|
|||
:disabled="wait"
|
||||
@click.stop="onClick"
|
||||
:aria-label="`${state} ${user.name || user.username}`"
|
||||
v-tooltip="full ? null : `${state} ${user.name || user.username}`"
|
||||
>
|
||||
<template v-if="!wait">
|
||||
<template v-if="isBlocking">
|
||||
<span v-if="full">{{ (state = i18n.ts.blocked) }}</span
|
||||
<span>{{ (state = i18n.ts.blocked) }}</span
|
||||
><i class="ph-prohibit ph-bold ph-lg"></i>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="hasPendingFollowRequestFromYou && user.isLocked"
|
||||
>
|
||||
<span v-if="full">{{
|
||||
<span>{{
|
||||
(state = i18n.ts.followRequestPending)
|
||||
}}</span
|
||||
><i class="ph-hourglass-medium ph-bold ph-lg"></i>
|
||||
|
@ -33,24 +39,24 @@
|
|||
v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"
|
||||
>
|
||||
<!-- つまりリモートフォローの場合。 -->
|
||||
<span v-if="full">{{ (state = i18n.ts.processing) }}</span
|
||||
<span>{{ (state = i18n.ts.processing) }}</span
|
||||
><i class="ph-circle-notch ph-bold ph-lg fa-pulse"></i>
|
||||
</template>
|
||||
<template v-else-if="isFollowing">
|
||||
<span v-if="full">{{ (state = i18n.ts.unfollow) }}</span
|
||||
<span>{{ (state = i18n.ts.unfollow) }}</span
|
||||
><i class="ph-minus ph-bold ph-lg"></i>
|
||||
</template>
|
||||
<template v-else-if="!isFollowing && user.isLocked">
|
||||
<span v-if="full">{{ (state = i18n.ts.followRequest) }}</span
|
||||
<span>{{ (state = i18n.ts.followRequest) }}</span
|
||||
><i class="ph-plus ph-bold ph-lg"></i>
|
||||
</template>
|
||||
<template v-else-if="!isFollowing && !user.isLocked">
|
||||
<span v-if="full">{{ (state = i18n.ts.follow) }}</span
|
||||
<span>{{ (state = i18n.ts.follow) }}</span
|
||||
><i class="ph-plus ph-bold ph-lg"></i>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="full">{{ (state = i18n.ts.processing) }}</span
|
||||
<span>{{ (state = i18n.ts.processing) }}</span
|
||||
><i class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"></i>
|
||||
</template>
|
||||
</button>
|
||||
|
@ -74,6 +80,7 @@ const props = withDefaults(
|
|||
user: Misskey.entities.UserDetailed;
|
||||
full?: boolean;
|
||||
large?: boolean;
|
||||
hideMenu?: boolean;
|
||||
}>(),
|
||||
{
|
||||
full: false,
|
||||
|
@ -179,6 +186,7 @@ onBeforeUnmount(() => {
|
|||
.menu {
|
||||
width: 3em;
|
||||
height: 2em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.follow-button {
|
||||
position: relative;
|
||||
|
@ -194,6 +202,7 @@ onBeforeUnmount(() => {
|
|||
height: 2em;
|
||||
border-radius: 100px;
|
||||
background: var(--bg);
|
||||
vertical-align: middle;
|
||||
|
||||
&.full {
|
||||
padding: 0.2em 0.7em;
|
||||
|
@ -209,6 +218,9 @@ onBeforeUnmount(() => {
|
|||
|
||||
&:not(.full) {
|
||||
width: 31px;
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
|
|
@ -186,6 +186,7 @@
|
|||
ref="reactButton"
|
||||
class="button _button reacted"
|
||||
@click="undoReact(appearNote)"
|
||||
v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
|
||||
>
|
||||
<i class="ph-minus ph-bold ph-lg"></i>
|
||||
</button>
|
||||
|
|
|
@ -119,6 +119,7 @@
|
|||
ref="reactButton"
|
||||
class="button _button reacted"
|
||||
@click="undoReact(appearNote)"
|
||||
v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
|
||||
>
|
||||
<i class="ph-minus ph-bold ph-lg"></i>
|
||||
</button>
|
||||
|
|
|
@ -219,6 +219,7 @@
|
|||
<MkFollowButton
|
||||
:user="notification.user"
|
||||
:full="true"
|
||||
:hideMenu="true"
|
||||
/></div
|
||||
></span>
|
||||
<span
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="jmgmzlwq _block">
|
||||
<div class="caution _block">
|
||||
{{ i18n.ts.remoteUserCaution
|
||||
}}<a
|
||||
class="link"
|
||||
|
@ -20,7 +20,7 @@ defineProps<{
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.jmgmzlwq {
|
||||
.caution {
|
||||
padding: 16px;
|
||||
font-size: 90%;
|
||||
background: var(--infoWarnBg);
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
:placeholder="i18n.ts.password"
|
||||
type="password"
|
||||
:with-password-toggle="true"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
data-cy-signin-password
|
||||
>
|
||||
|
@ -90,6 +91,7 @@
|
|||
v-model="password"
|
||||
type="password"
|
||||
:with-password-toggle="true"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
>
|
||||
<template #label>{{ i18n.ts.password }}</template>
|
||||
|
@ -97,24 +99,22 @@
|
|||
><i class="ph-lock ph-bold ph-lg"></i
|
||||
></template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
<vue3-otp-input
|
||||
input-classes="_otp_input"
|
||||
inputType="letter-numeric"
|
||||
separator=""
|
||||
:num-inputs="6"
|
||||
v-model="token"
|
||||
type="text"
|
||||
pattern="^[0-9]{6}$"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
:should-auto-focus="true"
|
||||
@on-change="updateToken"
|
||||
@on-complete="onSubmit"
|
||||
required
|
||||
>
|
||||
<template #label>{{ i18n.ts.token }}</template>
|
||||
<template #prefix
|
||||
><i class="ph-poker-chip ph-bold ph-lg"></i
|
||||
></template>
|
||||
</MkInput>
|
||||
/>
|
||||
<MkButton
|
||||
type="submit"
|
||||
:disabled="signing"
|
||||
primary
|
||||
style="margin: 0 auto"
|
||||
style="margin: 1rem auto auto"
|
||||
>{{
|
||||
signing ? i18n.ts.loggingIn : i18n.ts.login
|
||||
}}</MkButton
|
||||
|
@ -158,9 +158,9 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Vue3OtpInput from "vue3-otp-input";
|
||||
import { defineAsyncComponent } from "vue";
|
||||
import { toUnicode } from "punycode/";
|
||||
import { showSuspendedDialog } from "../scripts/show-suspended-dialog";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkInput from "@/components/form/input.vue";
|
||||
import MkInfo from "@/components/MkInfo.vue";
|
||||
|
@ -184,6 +184,10 @@ let queryingKey = $ref(false);
|
|||
let hCaptchaResponse = $ref(null);
|
||||
let reCaptchaResponse = $ref(null);
|
||||
|
||||
const updateToken = (value: string) => {
|
||||
token = value.toString();
|
||||
};
|
||||
|
||||
const meta = $computed(() => instance);
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -367,6 +371,14 @@ function resetPassword() {
|
|||
"closed"
|
||||
);
|
||||
}
|
||||
|
||||
function showSuspendedDialog() {
|
||||
os.alert({
|
||||
type: "error",
|
||||
title: i18n.ts.yourAccountSuspendedTitle,
|
||||
text: i18n.ts.yourAccountSuspendedDescription,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -376,10 +388,11 @@ function resetPassword() {
|
|||
margin: 0 auto 0 auto;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #ddd;
|
||||
background: var(--accentedBg);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border-radius: 100%;
|
||||
transition: background-image 0.2s ease-in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,7 +91,12 @@ if (props.src === "antenna") {
|
|||
connection.on("note", prepend);
|
||||
} else if (props.src === "home") {
|
||||
endpoint = "notes/timeline";
|
||||
connection = stream.useChannel("homeTimeline");
|
||||
query = {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
};
|
||||
connection = stream.useChannel("homeTimeline", {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
});
|
||||
connection.on("note", prepend);
|
||||
|
||||
connection2 = stream.useChannel("main");
|
||||
|
@ -102,28 +107,48 @@ if (props.src === "antenna") {
|
|||
tlHintClosed = defaultStore.state.tlHomeHintClosed;
|
||||
} else if (props.src === "local") {
|
||||
endpoint = "notes/local-timeline";
|
||||
connection = stream.useChannel("localTimeline");
|
||||
query = {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
};
|
||||
connection = stream.useChannel("localTimeline", {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
});
|
||||
connection.on("note", prepend);
|
||||
|
||||
tlHint = i18n.ts._tutorial.step5_4;
|
||||
tlHintClosed = defaultStore.state.tlLocalHintClosed;
|
||||
} else if (props.src === "recommended") {
|
||||
endpoint = "notes/recommended-timeline";
|
||||
connection = stream.useChannel("recommendedTimeline");
|
||||
query = {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
};
|
||||
connection = stream.useChannel("recommendedTimeline", {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
});
|
||||
connection.on("note", prepend);
|
||||
|
||||
tlHint = i18n.ts._tutorial.step5_6;
|
||||
tlHintClosed = defaultStore.state.tlRecommendedHintClosed;
|
||||
} else if (props.src === "social") {
|
||||
endpoint = "notes/hybrid-timeline";
|
||||
connection = stream.useChannel("hybridTimeline");
|
||||
query = {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
};
|
||||
connection = stream.useChannel("hybridTimeline", {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
});
|
||||
connection.on("note", prepend);
|
||||
|
||||
tlHint = i18n.ts._tutorial.step5_5;
|
||||
tlHintClosed = defaultStore.state.tlSocialHintClosed;
|
||||
} else if (props.src === "global") {
|
||||
endpoint = "notes/global-timeline";
|
||||
connection = stream.useChannel("globalTimeline");
|
||||
query = {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
};
|
||||
connection = stream.useChannel("globalTimeline", {
|
||||
withReplies: defaultStore.state.showTimelineReplies,
|
||||
});
|
||||
connection.on("note", prepend);
|
||||
|
||||
tlHint = i18n.ts._tutorial.step5_7;
|
||||
|
|
|
@ -45,12 +45,12 @@
|
|||
<MkNumber :value="user.followersCount" />
|
||||
</div>
|
||||
</div>
|
||||
<MkFollowButton
|
||||
v-if="$i && user.id != $i.id"
|
||||
class="koudoku-button"
|
||||
:user="user"
|
||||
mini
|
||||
/>
|
||||
<div class="koudoku-button">
|
||||
<MkFollowButton
|
||||
v-if="$i && user.id != $i.id"
|
||||
:user="user"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -148,6 +148,7 @@ defineProps<{
|
|||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -61,7 +61,7 @@ import { useInterval } from "@/scripts/use-interval";
|
|||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | number;
|
||||
modelValue: string | number | null;
|
||||
type?:
|
||||
| "text"
|
||||
| "number"
|
||||
|
@ -77,7 +77,7 @@ const props = defineProps<{
|
|||
pattern?: string;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
autocomplete?: boolean;
|
||||
autocomplete?: string;
|
||||
spellcheck?: boolean;
|
||||
step?: any;
|
||||
datalist?: string[];
|
||||
|
|
|
@ -1,38 +1,39 @@
|
|||
<template>
|
||||
<div class="vblkjoeq">
|
||||
<label>
|
||||
<div class="label"><slot name="label"></slot></div>
|
||||
<div
|
||||
ref="container"
|
||||
class="input"
|
||||
:class="{ inline, disabled, focused }"
|
||||
@click.prevent="onClick"
|
||||
tabindex="-1"
|
||||
<div class="label" @click="focus"><slot name="label"></slot></div>
|
||||
<div
|
||||
ref="container"
|
||||
class="input"
|
||||
:class="{ inline, disabled, focused }"
|
||||
@mousedown.prevent="show"
|
||||
>
|
||||
<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
|
||||
<select
|
||||
ref="inputEl"
|
||||
v-model="v"
|
||||
v-adaptive-border
|
||||
class="select"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@input="onInput"
|
||||
>
|
||||
<div ref="prefixEl" class="prefix">
|
||||
<slot name="prefix"></slot>
|
||||
</div>
|
||||
<select
|
||||
ref="inputEl"
|
||||
v-model="v"
|
||||
v-adaptive-border
|
||||
class="select"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@input="onInput"
|
||||
>
|
||||
<slot></slot>
|
||||
</select>
|
||||
<div ref="suffixEl" class="suffix">
|
||||
<i class="ph-caret-down ph-bold ph-lg"></i>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</select>
|
||||
<div ref="suffixEl" class="suffix">
|
||||
<i
|
||||
class="ph-caret-down ph-bold ph-lg"
|
||||
:class="[
|
||||
$style.chevron,
|
||||
{ [$style.chevronOpening]: opening },
|
||||
]"
|
||||
></i>
|
||||
</div>
|
||||
<div class="caption"><slot name="caption"></slot></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="caption"><slot name="caption"></slot></div>
|
||||
|
||||
<MkButton v-if="manualSave && changed" primary @click="updated"
|
||||
><i class="ph-floppy-disk-back ph-bold ph-lg"></i>
|
||||
|
@ -44,7 +45,6 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
ref,
|
||||
watch,
|
||||
|
@ -59,7 +59,7 @@ import { useInterval } from "@/scripts/use-interval";
|
|||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
modelValue: string | null;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
|
@ -73,7 +73,7 @@ const props = defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
(ev: "change", _ev: KeyboardEvent): void;
|
||||
(ev: "update:modelValue", value: string): void;
|
||||
(ev: "update:modelValue", value: string | null): void;
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
|
@ -81,6 +81,7 @@ const slots = useSlots();
|
|||
const { modelValue, autofocus } = toRefs(props);
|
||||
const v = ref(modelValue.value);
|
||||
const focused = ref(false);
|
||||
const opening = ref(false);
|
||||
const changed = ref(false);
|
||||
const invalid = ref(false);
|
||||
const filled = computed(() => v.value !== "" && v.value != null);
|
||||
|
@ -88,7 +89,7 @@ const inputEl = ref(null);
|
|||
const prefixEl = ref(null);
|
||||
const suffixEl = ref(null);
|
||||
const container = ref(null);
|
||||
const height = props.small ? 36 : props.large ? 40 : 38;
|
||||
const height = props.small ? 33 : props.large ? 39 : 36;
|
||||
|
||||
const focus = () => inputEl.value.focus();
|
||||
const onInput = (ev) => {
|
||||
|
@ -145,8 +146,9 @@ onMounted(() => {
|
|||
});
|
||||
});
|
||||
|
||||
const onClick = (ev: MouseEvent) => {
|
||||
function show(ev: MouseEvent) {
|
||||
focused.value = true;
|
||||
opening.value = true;
|
||||
|
||||
const menu = [];
|
||||
let options = slots.default!();
|
||||
|
@ -154,7 +156,7 @@ const onClick = (ev: MouseEvent) => {
|
|||
const pushOption = (option: VNode) => {
|
||||
menu.push({
|
||||
text: option.children,
|
||||
active: v.value === option.props.value,
|
||||
active: computed(() => v.value === option.props.value),
|
||||
action: () => {
|
||||
v.value = option.props.value;
|
||||
},
|
||||
|
@ -188,127 +190,136 @@ const onClick = (ev: MouseEvent) => {
|
|||
|
||||
os.popupMenu(menu, container.value, {
|
||||
width: container.value.offsetWidth,
|
||||
onClosing: () => {
|
||||
opening.value = false;
|
||||
},
|
||||
}).then(() => {
|
||||
focused.value = false;
|
||||
});
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vblkjoeq {
|
||||
> label {
|
||||
> .label {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0 8px 0;
|
||||
user-select: none;
|
||||
> .label {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0 8px 0;
|
||||
user-select: none;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .caption {
|
||||
font-size: 0.85em;
|
||||
padding: 8px 0 0 0;
|
||||
color: var(--fgTransparentWeak);
|
||||
> .caption {
|
||||
font-size: 0.85em;
|
||||
padding: 8px 0 0 0;
|
||||
color: var(--fgTransparentWeak);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .input {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-left: 0.2rem;
|
||||
margin-right: 0.2rem;
|
||||
|
||||
&:hover {
|
||||
> .select {
|
||||
border-color: var(--inputBorderHover) !important;
|
||||
}
|
||||
}
|
||||
> .input {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
> .select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: block;
|
||||
height: v-bind("height + 'px'");
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-weight: normal;
|
||||
font-size: 1em;
|
||||
color: var(--fg);
|
||||
background: var(--panel);
|
||||
border: solid 1px var(--panel);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s ease-out;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
border-color: var(--inputBorderHover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
> .select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: block;
|
||||
height: v-bind("height + 'px'");
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-weight: normal;
|
||||
font-size: 1em;
|
||||
color: var(--fg);
|
||||
background: var(--panel);
|
||||
border: solid 1px var(--panel);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s ease-out;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> .prefix,
|
||||
> .suffix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
padding: 0 12px;
|
||||
font-size: 1em;
|
||||
height: v-bind("height + 'px'");
|
||||
pointer-events: none;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .prefix,
|
||||
> .suffix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
padding: 0 12px;
|
||||
font-size: 1em;
|
||||
height: v-bind("height + 'px'");
|
||||
pointer-events: none;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> * {
|
||||
display: inline-block;
|
||||
min-width: 16px;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
> .prefix {
|
||||
left: 0;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
> .suffix {
|
||||
right: 0;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
> * {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
min-width: 16px;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
&.focused {
|
||||
> select {
|
||||
border-color: var(--accent) !important;
|
||||
}
|
||||
> .prefix {
|
||||
left: 0;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
> .suffix {
|
||||
right: 0;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
> select {
|
||||
border-color: var(--accent) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
|
||||
&,
|
||||
* {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
&,
|
||||
* {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" module>
|
||||
.chevron {
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
.chevronOpening {
|
||||
transform: rotateX(180deg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -22,7 +22,7 @@ const apiClient = new Misskey.api.APIClient({
|
|||
export const api = ((
|
||||
endpoint: string,
|
||||
data: Record<string, any> = {},
|
||||
token?: string | null | undefined,
|
||||
token?: string | null | undefined
|
||||
) => {
|
||||
pendingApiRequestsCount.value++;
|
||||
|
||||
|
@ -36,13 +36,16 @@ export const api = ((
|
|||
: undefined;
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
fetch(endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
credentials: "omit",
|
||||
cache: "no-cache",
|
||||
headers: authorization ? { authorization } : {},
|
||||
})
|
||||
fetch(
|
||||
endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
credentials: "omit",
|
||||
cache: "no-cache",
|
||||
headers: authorization ? { authorization } : {},
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
const body = res.status === 204 ? null : await res.json();
|
||||
|
||||
|
@ -65,7 +68,7 @@ export const api = ((
|
|||
export const apiGet = ((
|
||||
endpoint: string,
|
||||
data: Record<string, any> = {},
|
||||
token?: string | null | undefined,
|
||||
token?: string | null | undefined
|
||||
) => {
|
||||
pendingApiRequestsCount.value++;
|
||||
|
||||
|
@ -110,7 +113,7 @@ export const apiGet = ((
|
|||
export const apiWithDialog = ((
|
||||
endpoint: string,
|
||||
data: Record<string, any> = {},
|
||||
token?: string | null | undefined,
|
||||
token?: string | null | undefined
|
||||
) => {
|
||||
const promise = api(endpoint, data, token);
|
||||
promiseDialog(promise, null, (err) => {
|
||||
|
@ -127,7 +130,7 @@ export function promiseDialog<T extends Promise<any>>(
|
|||
promise: T,
|
||||
onSuccess?: ((res: any) => void) | null,
|
||||
onFailure?: ((err: Error) => void) | null,
|
||||
text?: string,
|
||||
text?: string
|
||||
): T {
|
||||
const showing = ref(true);
|
||||
const success = ref(false);
|
||||
|
@ -165,7 +168,7 @@ export function promiseDialog<T extends Promise<any>>(
|
|||
text: text,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
|
||||
return promise;
|
||||
|
@ -186,7 +189,7 @@ const zIndexes = {
|
|||
high: 3000000,
|
||||
};
|
||||
export function claimZIndex(
|
||||
priority: "low" | "middle" | "high" = "low",
|
||||
priority: "low" | "middle" | "high" = "low"
|
||||
): number {
|
||||
zIndexes[priority] += 100;
|
||||
return zIndexes[priority];
|
||||
|
@ -201,7 +204,7 @@ export async function popup(
|
|||
component: Component,
|
||||
props: Record<string, any>,
|
||||
events = {},
|
||||
disposeEvent?: string,
|
||||
disposeEvent?: string
|
||||
) {
|
||||
markRaw(component);
|
||||
|
||||
|
@ -242,7 +245,7 @@ export function pageWindow(path: string) {
|
|||
initialPath: path,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -257,7 +260,7 @@ export function modalPageWindow(path: string) {
|
|||
initialPath: path,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -268,7 +271,7 @@ export function toast(message: string) {
|
|||
message,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -289,7 +292,7 @@ export function alert(props: {
|
|||
resolve();
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -313,7 +316,7 @@ export function confirm(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -340,7 +343,7 @@ export function yesno(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -350,7 +353,10 @@ export function inputText(props: {
|
|||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: string | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}): Promise<
|
||||
| { canceled: true; result: undefined }
|
||||
| {
|
||||
|
@ -360,19 +366,17 @@ export function inputText(props: {
|
|||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkDialog,
|
||||
{
|
||||
type: props.type,
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
input: {
|
||||
type: props.type,
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
minLength: props.minLength,
|
||||
maxLength: props.maxLength,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -380,7 +384,7 @@ export function inputText(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -418,7 +422,7 @@ export function inputParagraph(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -428,6 +432,7 @@ export function inputNumber(props: {
|
|||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
default?: number | null;
|
||||
autocomplete?: string;
|
||||
}): Promise<
|
||||
| { canceled: true; result: undefined }
|
||||
| {
|
||||
|
@ -448,6 +453,7 @@ export function inputNumber(props: {
|
|||
input: {
|
||||
type: "number",
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
},
|
||||
},
|
||||
|
@ -456,7 +462,7 @@ export function inputNumber(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -475,11 +481,7 @@ export function inputDate(props: {
|
|||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkDialog,
|
||||
{
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
|
@ -492,13 +494,16 @@ export function inputDate(props: {
|
|||
{
|
||||
done: (result) => {
|
||||
resolve(
|
||||
(result && isFinite(new Date(result.result)))
|
||||
? { result: new Date(result.result), canceled: false }
|
||||
: { canceled: true },
|
||||
result
|
||||
? {
|
||||
result: new Date(result.result),
|
||||
canceled: false,
|
||||
}
|
||||
: { canceled: true }
|
||||
);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -524,7 +529,7 @@ export function select<C = any>(
|
|||
}[];
|
||||
}[];
|
||||
}
|
||||
),
|
||||
)
|
||||
): Promise<
|
||||
| { canceled: true; result: undefined }
|
||||
| {
|
||||
|
@ -534,11 +539,7 @@ export function select<C = any>(
|
|||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkDialog,
|
||||
{
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
|
@ -553,23 +554,19 @@ export function select<C = any>(
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function success() {
|
||||
export function success(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const showing = ref(true);
|
||||
window.setTimeout(() => {
|
||||
showing.value = false;
|
||||
}, 1000);
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkWaitingDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkWaitingDialog,
|
||||
{
|
||||
success: true,
|
||||
showing: showing,
|
||||
|
@ -577,20 +574,16 @@ export function success() {
|
|||
{
|
||||
done: () => resolve(),
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function waiting() {
|
||||
export function waiting(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const showing = ref(true);
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkWaitingDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkWaitingDialog,
|
||||
{
|
||||
success: false,
|
||||
showing: showing,
|
||||
|
@ -598,7 +591,7 @@ export function waiting() {
|
|||
{
|
||||
done: () => resolve(),
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -617,7 +610,7 @@ export function form(title, form) {
|
|||
resolve(result);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -636,7 +629,7 @@ export async function selectUser() {
|
|||
resolve(user);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -655,7 +648,7 @@ export async function selectInstance(): Promise<Misskey.entities.Instance> {
|
|||
resolve(instance);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -679,7 +672,7 @@ export async function selectDriveFile(multiple: boolean) {
|
|||
}
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -703,7 +696,7 @@ export async function selectDriveFolder(multiple: boolean) {
|
|||
}
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -725,7 +718,7 @@ export async function pickEmoji(src: HTMLElement | null, opts) {
|
|||
resolve(emoji);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -734,7 +727,7 @@ export async function cropImage(
|
|||
image: Misskey.entities.DriveFile,
|
||||
options: {
|
||||
aspectRatio: number;
|
||||
},
|
||||
}
|
||||
): Promise<Misskey.entities.DriveFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(
|
||||
|
@ -752,7 +745,7 @@ export async function cropImage(
|
|||
resolve(x);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -767,7 +760,7 @@ let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
|
|||
export async function openEmojiPicker(
|
||||
src?: HTMLElement,
|
||||
opts,
|
||||
initialTextarea: typeof activeTextarea,
|
||||
initialTextarea: typeof activeTextarea
|
||||
) {
|
||||
if (openingEmojiPicker) return;
|
||||
|
||||
|
@ -783,13 +776,14 @@ export async function openEmojiPicker(
|
|||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
for (const node of Array.from(record.addedNodes).filter(
|
||||
(node) => node instanceof HTMLElement,
|
||||
(node) => node instanceof HTMLElement
|
||||
) as HTMLElement[]) {
|
||||
const textareas = node.querySelectorAll("textarea, input");
|
||||
for (const textarea of Array.from(textareas).filter(
|
||||
(textarea) => textarea.dataset.preventEmojiInsert == null,
|
||||
(textarea) => textarea.dataset.preventEmojiInsert == null
|
||||
)) {
|
||||
if (document.activeElement === textarea) activeTextarea = textarea;
|
||||
if (document.activeElement === textarea)
|
||||
activeTextarea = textarea;
|
||||
textarea.addEventListener("focus", () => {
|
||||
activeTextarea = textarea;
|
||||
});
|
||||
|
@ -827,7 +821,7 @@ export async function openEmojiPicker(
|
|||
openingEmojiPicker = null;
|
||||
observer.disconnect();
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -839,7 +833,7 @@ export function popupMenu(
|
|||
width?: number;
|
||||
viaKeyboard?: boolean;
|
||||
noReturnFocus?: boolean;
|
||||
},
|
||||
}
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let dispose;
|
||||
|
@ -862,7 +856,7 @@ export function popupMenu(
|
|||
resolve();
|
||||
dispose();
|
||||
},
|
||||
},
|
||||
}
|
||||
).then((res) => {
|
||||
dispose = res.dispose;
|
||||
});
|
||||
|
@ -871,7 +865,7 @@ export function popupMenu(
|
|||
|
||||
export function contextMenu(
|
||||
items: MenuItem[] | Ref<MenuItem[]>,
|
||||
ev: MouseEvent,
|
||||
ev: MouseEvent
|
||||
) {
|
||||
ev.preventDefault();
|
||||
return new Promise((resolve, reject) => {
|
||||
|
@ -891,7 +885,7 @@ export function contextMenu(
|
|||
resolve();
|
||||
dispose();
|
||||
},
|
||||
},
|
||||
}
|
||||
).then((res) => {
|
||||
dispose = res.dispose;
|
||||
});
|
||||
|
|
100
packages/client/src/pages/settings/2fa.qrdialog.vue
Normal file
100
packages/client/src/pages/settings/2fa.qrdialog.vue
Normal file
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<MkModal
|
||||
ref="dialogEl"
|
||||
:prefer-type="'dialog'"
|
||||
:z-priority="'low'"
|
||||
@click="cancel"
|
||||
@close="cancel"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<div :class="$style.root" class="_gaps_m">
|
||||
<I18n :src="i18n.ts._2fa.step1" tag="div">
|
||||
<template #a>
|
||||
<a
|
||||
href="https://authpass.app/"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class="_link"
|
||||
>AuthPass</a
|
||||
>
|
||||
</template>
|
||||
<template #b>
|
||||
<a
|
||||
href="https://support.google.com/accounts/answer/1066447"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class="_link"
|
||||
>Google Authenticator</a
|
||||
>
|
||||
</template>
|
||||
</I18n>
|
||||
<div>
|
||||
{{ i18n.ts._2fa.step2 }}<br />
|
||||
{{ i18n.ts._2fa.step2Click }}
|
||||
</div>
|
||||
<a :href="twoFactorData.url"
|
||||
><img :class="$style.qr" :src="twoFactorData.qr"
|
||||
/></a>
|
||||
<div style="max-width: 610px">
|
||||
<MkKeyValue :copy="twoFactorData.url">
|
||||
<template #key>{{ i18n.ts._2fa.step2Url }}</template>
|
||||
<template #value>{{ twoFactorData.url }}</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
<div class="_flexList">
|
||||
<MkButton primary @click="ok">{{ i18n.ts.next }}</MkButton>
|
||||
<MkButton @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import MkKeyValue from "@/components/MkKeyValue.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
defineProps<{
|
||||
twoFactorData: {
|
||||
qr: string;
|
||||
url: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "ok"): void;
|
||||
(ev: "cancel"): void;
|
||||
(ev: "closed"): void;
|
||||
}>();
|
||||
|
||||
const cancel = () => {
|
||||
emit("cancel");
|
||||
emit("closed");
|
||||
};
|
||||
|
||||
const ok = () => {
|
||||
emit("ok");
|
||||
emit("closed");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
margin: auto;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: calc(100svw - 64px);
|
||||
box-sizing: border-box;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.qr {
|
||||
width: 20em;
|
||||
max-width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 3px solid var(--accent);
|
||||
}
|
||||
</style>
|
|
@ -1,300 +1,330 @@
|
|||
<template>
|
||||
<div>
|
||||
<MkButton
|
||||
v-if="!twoFactorData && !$i.twoFactorEnabled"
|
||||
@click="register"
|
||||
>{{ i18n.ts._2fa.registerDevice }}</MkButton
|
||||
>
|
||||
<template v-if="$i.twoFactorEnabled">
|
||||
<p>{{ i18n.ts._2fa.alreadyRegistered }}</p>
|
||||
<MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton>
|
||||
</template>
|
||||
<FormSection :first="first">
|
||||
<template #label>{{ i18n.ts["2fa"] }}</template>
|
||||
|
||||
<template v-if="supportsCredentials && $i.twoFactorEnabled">
|
||||
<hr class="totp-method-sep" />
|
||||
|
||||
<h2 class="heading">{{ i18n.ts.securityKey }}</h2>
|
||||
<p>{{ i18n.ts._2fa.securityKeyInfo }}</p>
|
||||
<div class="key-list">
|
||||
<div v-for="key in $i.securityKeysList" class="key">
|
||||
<h3>{{ key.name }}</h3>
|
||||
<div class="last-used">
|
||||
{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed" />
|
||||
</div>
|
||||
<MkButton @click="unregisterKey(key)">{{
|
||||
i18n.ts.unregister
|
||||
}}</MkButton>
|
||||
<div v-if="$i" class="_gaps_s">
|
||||
<MkFolder>
|
||||
<template #icon
|
||||
><i
|
||||
class="ph-shield-check ph-bold ph-lg"
|
||||
style="margin-right: 0.5rem"
|
||||
></i
|
||||
></template>
|
||||
<template #label>{{ i18n.ts.totp }}</template>
|
||||
<template #caption>{{ i18n.ts.totpDescription }}</template>
|
||||
<div v-if="$i.twoFactorEnabled" class="_gaps_s">
|
||||
<div v-text="i18n.ts._2fa.alreadyRegistered" />
|
||||
<template v-if="$i.securityKeysList.length > 0">
|
||||
<MkButton @click="renewTOTP"
|
||||
><i
|
||||
class="ph-shield-check ph-bold ph-lg"
|
||||
style="margin-right: 0.5rem"
|
||||
></i
|
||||
>{{ i18n.ts._2fa.renewTOTP }}</MkButton
|
||||
>
|
||||
<MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
|
||||
</template>
|
||||
<MkButton v-else @click="unregisterTOTP"
|
||||
><i
|
||||
class="ph-shield-slash ph-bold ph-lg"
|
||||
style="margin-right: 0.5rem"
|
||||
></i
|
||||
>{{ i18n.ts.unregister }}</MkButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkButton
|
||||
v-else-if="!twoFactorData && !$i.twoFactorEnabled"
|
||||
@click="registerTOTP"
|
||||
>{{ i18n.ts._2fa.registerTOTP }}</MkButton
|
||||
>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #icon
|
||||
><i
|
||||
class="ph-key ph-bold ph-lg"
|
||||
style="margin-right: 0.5rem"
|
||||
></i
|
||||
></template>
|
||||
<template #label>{{ i18n.ts.securityKeyAndPasskey }}</template>
|
||||
<div class="_gaps_s">
|
||||
<MkInfo>
|
||||
{{ i18n.ts._2fa.securityKeyInfo }}<br />
|
||||
<br />
|
||||
{{ i18n.ts._2fa.chromePasskeyNotSupported }}
|
||||
</MkInfo>
|
||||
|
||||
<MkInfo v-if="!supportsCredentials" warn>
|
||||
{{ i18n.ts._2fa.securityKeyNotSupported }}
|
||||
</MkInfo>
|
||||
|
||||
<MkInfo
|
||||
v-else-if="supportsCredentials && !$i.twoFactorEnabled"
|
||||
warn
|
||||
>
|
||||
{{ i18n.ts._2fa.registerTOTPBeforeKey }}
|
||||
</MkInfo>
|
||||
|
||||
<template v-else>
|
||||
<MkButton primary @click="addSecurityKey"
|
||||
><i
|
||||
class="ph-key ph-bold ph-lg"
|
||||
style="margin-right: 0.5rem"
|
||||
></i
|
||||
>{{ i18n.ts._2fa.registerSecurityKey }}</MkButton
|
||||
>
|
||||
<MkFolder
|
||||
v-for="key in $i.securityKeysList"
|
||||
:key="key.id"
|
||||
>
|
||||
<template #label>{{ key.name }}</template>
|
||||
<template #suffix
|
||||
><I18n :src="i18n.ts.lastUsedAt"
|
||||
><template #t
|
||||
><MkTime
|
||||
:time="
|
||||
key.lastUsed
|
||||
" /></template></I18n
|
||||
></template>
|
||||
<div class="_buttons">
|
||||
<MkButton @click="renameKey(key)"
|
||||
><i
|
||||
class="ph-pencil-line ph-bold ph-lg"
|
||||
></i>
|
||||
{{ i18n.ts.rename }}</MkButton
|
||||
>
|
||||
<MkButton danger @click="unregisterKey(key)"
|
||||
><i class="ph-trash ph-bold ph-lg"></i>
|
||||
{{ i18n.ts.unregister }}</MkButton
|
||||
>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkSwitch
|
||||
v-if="$i.securityKeysList.length > 0"
|
||||
v-model="usePasswordLessLogin"
|
||||
@update:modelValue="updatePasswordLessLogin"
|
||||
>{{ i18n.ts.passwordLessLogin }}</MkSwitch
|
||||
:disabled="
|
||||
!$i.twoFactorEnabled || $i.securityKeysList.length === 0
|
||||
"
|
||||
:modelValue="usePasswordLessLogin"
|
||||
@update:modelValue="(v) => updatePasswordLessLogin(v)"
|
||||
>
|
||||
|
||||
<MkInfo
|
||||
v-if="registration && registration.error"
|
||||
style="margin-bottom: 1rem"
|
||||
warn
|
||||
>{{ i18n.ts.error }}: {{ registration.error }}</MkInfo
|
||||
>
|
||||
<MkButton
|
||||
v-if="!registration || registration.error"
|
||||
@click="addSecurityKey"
|
||||
>{{ i18n.ts._2fa.registerKey }}</MkButton
|
||||
>
|
||||
|
||||
<ol v-if="registration && !registration.error">
|
||||
<li v-if="registration.stage >= 0">
|
||||
{{ i18n.ts.tapSecurityKey }}
|
||||
<i
|
||||
v-if="registration.saving && registration.stage == 0"
|
||||
class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"
|
||||
></i>
|
||||
</li>
|
||||
<li v-if="registration.stage >= 1">
|
||||
<MkForm
|
||||
:disabled="
|
||||
registration.stage != 1 || registration.saving
|
||||
"
|
||||
>
|
||||
<MkInput v-model="keyName" :max="30">
|
||||
<template #label>{{
|
||||
i18n.ts.securityKeyName
|
||||
}}</template>
|
||||
</MkInput>
|
||||
<MkButton
|
||||
:disabled="keyName.length == 0"
|
||||
@click="registerKey"
|
||||
>{{ i18n.ts.registerSecurityKey }}</MkButton
|
||||
>
|
||||
<i
|
||||
v-if="
|
||||
registration.saving && registration.stage == 1
|
||||
"
|
||||
class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"
|
||||
></i>
|
||||
</MkForm>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
<div v-if="twoFactorData && !$i.twoFactorEnabled">
|
||||
<ol style="margin: 0; padding: 0 0 0 1em">
|
||||
<li>
|
||||
<I18n :src="i18n.ts._2fa.step1" tag="span">
|
||||
<template #a>
|
||||
<a
|
||||
href="https://authpass.app/"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class="_link"
|
||||
>AuthPass</a
|
||||
>
|
||||
</template>
|
||||
<template #b>
|
||||
<a
|
||||
href="https://support.google.com/accounts/answer/1066447"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class="_link"
|
||||
>Google Authenticator</a
|
||||
>
|
||||
</template>
|
||||
</I18n>
|
||||
</li>
|
||||
<li>
|
||||
{{ i18n.ts._2fa.step2 }}<br /><img
|
||||
:src="twoFactorData.qr"
|
||||
/>
|
||||
<p>
|
||||
{{ i18n.ts._2fa.step2Url }}<br />{{ twoFactorData.url }}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
{{ i18n.ts._2fa.step3 }}<br />
|
||||
<MkInput
|
||||
v-model="token"
|
||||
type="text"
|
||||
pattern="^[0-9]{6}$"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
><template #label>{{
|
||||
i18n.ts.token
|
||||
}}</template></MkInput
|
||||
>
|
||||
<MkButton primary @click="submit">{{
|
||||
i18n.ts.done
|
||||
}}</MkButton>
|
||||
</li>
|
||||
</ol>
|
||||
<MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo>
|
||||
<template #label>{{ i18n.ts.passwordLessLogin }}</template>
|
||||
<template #caption>{{
|
||||
i18n.ts.passwordLessLoginDescription
|
||||
}}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, defineAsyncComponent } from "vue";
|
||||
import { hostname } from "@/config";
|
||||
import { byteify, hexify, stringify } from "@/scripts/2fa";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkInfo from "@/components/MkInfo.vue";
|
||||
import MkInput from "@/components/form/input.vue";
|
||||
import MkSwitch from "@/components/form/switch.vue";
|
||||
import FormSection from "@/components/form/section.vue";
|
||||
import MkFolder from "@/components/MkFolder.vue";
|
||||
import * as os from "@/os";
|
||||
import { $i } from "@/account";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
// メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
first?: boolean;
|
||||
}>(),
|
||||
{
|
||||
first: false,
|
||||
}
|
||||
);
|
||||
|
||||
const twoFactorData = ref<any>(null);
|
||||
const supportsCredentials = ref(!!navigator.credentials);
|
||||
const usePasswordLessLogin = ref($i!.usePasswordLessLogin);
|
||||
const registration = ref<any>(null);
|
||||
const keyName = ref("");
|
||||
const token = ref(null);
|
||||
const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
|
||||
|
||||
function register() {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
async function registerTOTP() {
|
||||
const password = await os.inputText({
|
||||
title: i18n.ts._2fa.registerTOTP,
|
||||
text: i18n.ts.currentPassword,
|
||||
type: "password",
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api("i/2fa/register", {
|
||||
password: password,
|
||||
}).then((data) => {
|
||||
twoFactorData.value = data;
|
||||
});
|
||||
autocomplete: "current-password",
|
||||
});
|
||||
if (password.canceled) return;
|
||||
|
||||
const twoFactorData = await os.apiWithDialog("i/2fa/register", {
|
||||
password: password.result,
|
||||
});
|
||||
|
||||
const qrdialog = await new Promise<boolean>((res) => {
|
||||
os.popup(
|
||||
defineAsyncComponent(() => import("./2fa.qrdialog.vue")),
|
||||
{
|
||||
twoFactorData,
|
||||
},
|
||||
{
|
||||
ok: () => res(true),
|
||||
cancel: () => res(false),
|
||||
},
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
if (!qrdialog) return;
|
||||
|
||||
const token = await os.inputNumber({
|
||||
title: i18n.ts._2fa.step3Title,
|
||||
text: i18n.ts._2fa.step3,
|
||||
autocomplete: "one-time-code",
|
||||
});
|
||||
if (token.canceled) return;
|
||||
|
||||
await os.apiWithDialog("i/2fa/done", {
|
||||
token: token.result.toString(),
|
||||
});
|
||||
|
||||
await os.alert({
|
||||
type: "success",
|
||||
text: i18n.ts._2fa.step4,
|
||||
});
|
||||
}
|
||||
|
||||
function unregister() {
|
||||
function unregisterTOTP() {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: "password",
|
||||
autocomplete: "current-password",
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api("i/2fa/unregister", {
|
||||
os.apiWithDialog("i/2fa/unregister", {
|
||||
password: password,
|
||||
})
|
||||
.then(() => {
|
||||
usePasswordLessLogin.value = false;
|
||||
updatePasswordLessLogin();
|
||||
})
|
||||
.then(() => {
|
||||
os.success();
|
||||
$i!.twoFactorEnabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function submit() {
|
||||
os.api("i/2fa/done", {
|
||||
token: token.value,
|
||||
})
|
||||
.then(() => {
|
||||
os.success();
|
||||
$i!.twoFactorEnabled = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
}).catch((error) => {
|
||||
os.alert({
|
||||
type: "error",
|
||||
text: err,
|
||||
text: error,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerKey() {
|
||||
registration.value.saving = true;
|
||||
os.api("i/2fa/key-done", {
|
||||
password: registration.value.password,
|
||||
name: keyName.value,
|
||||
challengeId: registration.value.challengeId,
|
||||
function renewTOTP() {
|
||||
os.confirm({
|
||||
type: "question",
|
||||
title: i18n.ts._2fa.renewTOTP,
|
||||
text: i18n.ts._2fa.renewTOTPConfirm,
|
||||
okText: i18n.ts._2fa.renewTOTPOk,
|
||||
cancelText: i18n.ts._2fa.renewTOTPCancel,
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
registerTOTP();
|
||||
});
|
||||
}
|
||||
|
||||
async function unregisterKey(key) {
|
||||
const confirm = await os.confirm({
|
||||
type: "question",
|
||||
title: i18n.ts._2fa.removeKey,
|
||||
text: i18n.t("_2fa.removeKeyConfirm", { name: key.name }),
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
|
||||
const password = await os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: "password",
|
||||
autocomplete: "current-password",
|
||||
});
|
||||
if (password.canceled) return;
|
||||
|
||||
await os.apiWithDialog("i/2fa/remove-key", {
|
||||
password: password.result,
|
||||
credentialId: key.id,
|
||||
});
|
||||
os.success();
|
||||
}
|
||||
|
||||
async function renameKey(key) {
|
||||
const name = await os.inputText({
|
||||
title: i18n.ts.rename,
|
||||
default: key.name,
|
||||
type: "text",
|
||||
minLength: 1,
|
||||
maxLength: 30,
|
||||
});
|
||||
if (name.canceled) return;
|
||||
|
||||
await os.apiWithDialog("i/2fa/update-key", {
|
||||
name: name.result,
|
||||
credentialId: key.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function addSecurityKey() {
|
||||
const password = await os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: "password",
|
||||
autocomplete: "current-password",
|
||||
});
|
||||
if (password.canceled) return;
|
||||
|
||||
const challenge: any = await os.apiWithDialog("i/2fa/register-key", {
|
||||
password: password.result,
|
||||
});
|
||||
|
||||
const name = await os.inputText({
|
||||
title: i18n.ts._2fa.registerSecurityKey,
|
||||
text: i18n.ts._2fa.securityKeyName,
|
||||
type: "text",
|
||||
minLength: 1,
|
||||
maxLength: 30,
|
||||
});
|
||||
if (name.canceled) return;
|
||||
|
||||
const webAuthnCreation = navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: byteify(challenge.challenge, "base64"),
|
||||
rp: {
|
||||
id: hostname,
|
||||
name: "Misskey",
|
||||
},
|
||||
user: {
|
||||
id: byteify($i!.id, "ascii"),
|
||||
name: $i!.username,
|
||||
displayName: $i!.name,
|
||||
},
|
||||
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
|
||||
timeout: 60000,
|
||||
attestation: "direct",
|
||||
},
|
||||
}) as Promise<
|
||||
| (PublicKeyCredential & { response: AuthenticatorAttestationResponse })
|
||||
| null
|
||||
>;
|
||||
|
||||
const credential = await os.promiseDialog(
|
||||
webAuthnCreation,
|
||||
null,
|
||||
() => {}, // ユーザーのキャンセルはrejectなのでエラーダイアログを出さない
|
||||
i18n.ts._2fa.tapSecurityKey
|
||||
);
|
||||
if (!credential) return;
|
||||
|
||||
await os.apiWithDialog("i/2fa/key-done", {
|
||||
password: password.result,
|
||||
name: name.result,
|
||||
challengeId: challenge.challengeId,
|
||||
// we convert each 16 bits to a string to serialise
|
||||
clientDataJSON: stringify(
|
||||
registration.value.credential.response.clientDataJSON
|
||||
),
|
||||
attestationObject: hexify(
|
||||
registration.value.credential.response.attestationObject
|
||||
),
|
||||
}).then((key) => {
|
||||
registration.value = null;
|
||||
key!.lastUsed = new Date();
|
||||
os.success();
|
||||
clientDataJSON: stringify(credential.response.clientDataJSON),
|
||||
attestationObject: hexify(credential.response.attestationObject),
|
||||
});
|
||||
}
|
||||
|
||||
function unregisterKey(key) {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: "password",
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
return os
|
||||
.api("i/2fa/remove-key", {
|
||||
password,
|
||||
credentialId: key.id,
|
||||
})
|
||||
.then(() => {
|
||||
usePasswordLessLogin.value = false;
|
||||
updatePasswordLessLogin();
|
||||
})
|
||||
.then(() => {
|
||||
os.success();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addSecurityKey() {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: "password",
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api("i/2fa/register-key", {
|
||||
password,
|
||||
})
|
||||
.then((reg) => {
|
||||
registration.value = {
|
||||
password,
|
||||
challengeId: reg!.challengeId,
|
||||
stage: 0,
|
||||
publicKeyOptions: {
|
||||
challenge: byteify(reg!.challenge, "base64"),
|
||||
rp: {
|
||||
id: hostname,
|
||||
name: "Calckey",
|
||||
},
|
||||
user: {
|
||||
id: byteify($i!.id, "ascii"),
|
||||
name: $i!.username,
|
||||
displayName: $i!.name,
|
||||
},
|
||||
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
|
||||
timeout: 60000,
|
||||
attestation: "direct",
|
||||
},
|
||||
saving: true,
|
||||
};
|
||||
return navigator.credentials.create({
|
||||
publicKey: registration.value.publicKeyOptions,
|
||||
});
|
||||
})
|
||||
.then((credential) => {
|
||||
registration.value.credential = credential;
|
||||
registration.value.saving = false;
|
||||
registration.value.stage = 1;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Error while registering?", err);
|
||||
registration.value.error = err.message;
|
||||
registration.value.stage = -1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function updatePasswordLessLogin() {
|
||||
await os.api("i/2fa/password-less", {
|
||||
value: !!usePasswordLessLogin.value,
|
||||
async function updatePasswordLessLogin(value: boolean) {
|
||||
await os.apiWithDialog("i/2fa/password-less", {
|
||||
value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
<FormSwitch v-model="disablePagesScript" class="_formBlock">{{
|
||||
i18n.ts.disablePagesScript
|
||||
}}</FormSwitch>
|
||||
<FormSwitch v-model="profile.showTimelineReplies" class="_formBlock"
|
||||
<FormSwitch v-model="showTimelineReplies" class="_formBlock"
|
||||
>{{ i18n.ts.flagShowTimelineReplies
|
||||
}}<template #caption
|
||||
>{{ i18n.ts.flagShowTimelineRepliesDescription }}
|
||||
|
@ -258,24 +258,6 @@ const lang = ref(localStorage.getItem("lang"));
|
|||
const fontSize = ref(localStorage.getItem("fontSize"));
|
||||
const useSystemFont = ref(localStorage.getItem("useSystemFont") != null);
|
||||
|
||||
const profile = reactive({
|
||||
showTimelineReplies: $i?.showTimelineReplies,
|
||||
});
|
||||
watch(
|
||||
() => profile,
|
||||
() => {
|
||||
save();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
function save() {
|
||||
os.apiWithDialog("i/update", {
|
||||
showTimelineReplies: !!profile.showTimelineReplies,
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadAsk() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: "info",
|
||||
|
@ -360,6 +342,9 @@ const swipeOnDesktop = computed(
|
|||
const showAdminUpdates = computed(
|
||||
defaultStore.makeGetterSetter("showAdminUpdates")
|
||||
);
|
||||
const showTimelineReplies = computed(
|
||||
defaultStore.makeGetterSetter("showTimelineReplies")
|
||||
);
|
||||
|
||||
watch(lang, () => {
|
||||
localStorage.setItem("lang", lang.value as string);
|
||||
|
|
|
@ -331,6 +331,11 @@ definePageMetadata(INFO);
|
|||
<style lang="scss" scoped>
|
||||
.vvcocwet {
|
||||
> .body {
|
||||
.wallpaper & {
|
||||
background: var(--bg);
|
||||
padding: var(--margin);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
> .nav {
|
||||
.baaadecd {
|
||||
> .info {
|
||||
|
|
|
@ -115,6 +115,7 @@ const defaultStoreSaveKeys: (keyof (typeof defaultStore)["state"])[] = [
|
|||
"enableCustomKaTeXMacro",
|
||||
"enableEmojiReactions",
|
||||
"showEmojisInReactionNotifications",
|
||||
"showTimelineReplies",
|
||||
];
|
||||
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
|
||||
"lightTheme",
|
||||
|
|
|
@ -1,16 +1,13 @@
|
|||
<template>
|
||||
<div class="_formRoot">
|
||||
<FormSection>
|
||||
<FormSection style="border: none !important">
|
||||
<template #label>{{ i18n.ts.password }}</template>
|
||||
<FormButton primary @click="change()">{{
|
||||
<MkButton primary @click="change()">{{
|
||||
i18n.ts.changePassword
|
||||
}}</FormButton>
|
||||
}}</MkButton>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
|
||||
<X2fa />
|
||||
</FormSection>
|
||||
<X2fa />
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.signinHistory }}</template>
|
||||
|
@ -43,9 +40,9 @@
|
|||
|
||||
<FormSection>
|
||||
<FormSlot>
|
||||
<FormButton danger @click="regenerateToken"
|
||||
<MkButton danger @click="regenerateToken"
|
||||
><i class="ph-arrows-clockwise ph-bold ph-lg"></i>
|
||||
{{ i18n.ts.regenerateLoginToken }}</FormButton
|
||||
{{ i18n.ts.regenerateLoginToken }}</MkButton
|
||||
>
|
||||
<template #caption>{{
|
||||
i18n.ts.regenerateLoginTokenDescription
|
||||
|
@ -59,7 +56,7 @@
|
|||
import X2fa from "./2fa.vue";
|
||||
import FormSection from "@/components/form/section.vue";
|
||||
import FormSlot from "@/components/form/slot.vue";
|
||||
import FormButton from "@/components/MkButton.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkPagination from "@/components/MkPagination.vue";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
@ -70,11 +67,12 @@ const pagination = {
|
|||
limit: 5,
|
||||
};
|
||||
|
||||
async function change(): Promise<void> {
|
||||
async function change() {
|
||||
const { canceled: canceled1, result: currentPassword } = await os.inputText(
|
||||
{
|
||||
title: i18n.ts.currentPassword,
|
||||
type: "password",
|
||||
autocomplete: "current-password",
|
||||
}
|
||||
);
|
||||
if (canceled1) return;
|
||||
|
@ -82,12 +80,14 @@ async function change(): Promise<void> {
|
|||
const { canceled: canceled2, result: newPassword } = await os.inputText({
|
||||
title: i18n.ts.newPassword,
|
||||
type: "password",
|
||||
autocomplete: "new-password",
|
||||
});
|
||||
if (canceled2) return;
|
||||
|
||||
const { canceled: canceled3, result: newPassword2 } = await os.inputText({
|
||||
title: i18n.ts.newPasswordRetype,
|
||||
type: "password",
|
||||
autocomplete: "new-password",
|
||||
});
|
||||
if (canceled3) return;
|
||||
|
||||
|
@ -105,13 +105,13 @@ async function change(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
function regenerateToken(): void {
|
||||
function regenerateToken() {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: "password",
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api("i/regenerate_token", {
|
||||
os.api("i/regenerate-token", {
|
||||
password: password,
|
||||
});
|
||||
});
|
||||
|
@ -129,7 +129,7 @@ definePageMetadata({
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.timnmucd {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 6px;
|
||||
|
|
|
@ -354,7 +354,6 @@ onMounted(() => {
|
|||
> .tl {
|
||||
background: none;
|
||||
border-radius: var(--radius);
|
||||
overflow: clip;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -7,10 +7,6 @@
|
|||
:class="{ wide: !narrow }"
|
||||
>
|
||||
<div class="main">
|
||||
<!-- TODO -->
|
||||
<!-- <div class="punished" v-if="user.isSuspended"><i class="ph-warning ph-bold ph-lg" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> -->
|
||||
<!-- <div class="punished" v-if="user.isSilenced"><i class="ph-warning ph-bold ph-lg" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
|
||||
|
||||
<div class="profile">
|
||||
<MkMoved
|
||||
v-if="user.movedToUri"
|
||||
|
@ -32,12 +28,38 @@
|
|||
></div>
|
||||
<div class="fade"></div>
|
||||
<div class="title">
|
||||
<div class="nameCollumn">
|
||||
<div class="nameColumn">
|
||||
<MkUserName
|
||||
class="name"
|
||||
:user="user"
|
||||
:nowrap="true"
|
||||
/>
|
||||
<div v-if="$i?.isModerator || $i?.isAdmin">
|
||||
<span
|
||||
v-if="user.isSilenced"
|
||||
style="
|
||||
color: var(--warn);
|
||||
padding: 5px;
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="ph-warning ph-bold ph-lg"
|
||||
></i>
|
||||
{{ i18n.ts.silenced }}
|
||||
</span>
|
||||
<span
|
||||
v-if="user.isSuspended"
|
||||
style="
|
||||
color: var(--error);
|
||||
padding: 5px;
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="ph-warning ph-bold ph-lg"
|
||||
></i>
|
||||
{{ i18n.ts.suspended }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="
|
||||
$i &&
|
||||
|
@ -102,7 +124,7 @@
|
|||
:show-indicator="true"
|
||||
/>
|
||||
<div class="title">
|
||||
<div class="nameCollumn">
|
||||
<div class="nameColumn">
|
||||
<MkUserName
|
||||
class="name"
|
||||
:user="user"
|
||||
|
@ -117,6 +139,25 @@
|
|||
class="followed"
|
||||
>{{ i18n.ts.followsYou }}</span
|
||||
>
|
||||
<div v-if="$i?.isModerator || $i?.isAdmin">
|
||||
<span
|
||||
v-if="user.isSilenced"
|
||||
style="color: var(--warn); padding: 5px"
|
||||
>
|
||||
<i class="ph-warning ph-bold ph-lg"></i>
|
||||
{{ i18n.ts.silenced }}
|
||||
</span>
|
||||
<span
|
||||
v-if="user.isSuspended"
|
||||
style="
|
||||
color: var(--error);
|
||||
padding: 5px;
|
||||
"
|
||||
>
|
||||
<i class="ph-warning ph-bold ph-lg"></i>
|
||||
{{ i18n.ts.suspended }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom">
|
||||
<span class="username"
|
||||
|
@ -400,9 +441,11 @@ const timeForThem = $computed(() => {
|
|||
timeZone: tz,
|
||||
hour12: false,
|
||||
});
|
||||
return ` (${theirTime.split(",")[1].trim().split(":")[0]}:${theirTime
|
||||
.split(" ")[1]
|
||||
.slice(-5, -3)})`;
|
||||
return ` (${theirTime
|
||||
.split(",")[1]
|
||||
.trim()
|
||||
.split(":")[0]
|
||||
.replace("24", "0")}:${theirTime.split(" ")[1].slice(-5, -3)})`;
|
||||
}
|
||||
|
||||
return "";
|
||||
|
@ -443,11 +486,6 @@ onUnmounted(() => {
|
|||
<style lang="scss" scoped>
|
||||
.ftskorzw {
|
||||
> .main {
|
||||
> .punished {
|
||||
font-size: 0.8em;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
> .profile {
|
||||
> .main {
|
||||
position: relative;
|
||||
|
@ -513,7 +551,7 @@ onUnmounted(() => {
|
|||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
|
||||
> .nameCollumn {
|
||||
> .nameColumn {
|
||||
display: block;
|
||||
> .name {
|
||||
margin: 0;
|
||||
|
@ -621,7 +659,7 @@ onUnmounted(() => {
|
|||
font-weight: bold;
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
|
||||
> .nameCollumn {
|
||||
> .nameColumn {
|
||||
display: block;
|
||||
> .name {
|
||||
margin: 0;
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
||||
export function showSuspendedDialog() {
|
||||
return os.alert({
|
||||
type: "error",
|
||||
title: i18n.ts.yourAccountSuspendedTitle,
|
||||
text: i18n.ts.yourAccountSuspendedDescription,
|
||||
});
|
||||
}
|
|
@ -330,6 +330,10 @@ export const defaultStore = markRaw(
|
|||
where: "account",
|
||||
default: true,
|
||||
},
|
||||
showTimelineReplies: {
|
||||
where: "device",
|
||||
default: true,
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
@ -13,3 +13,12 @@ export const stream = markRaw(
|
|||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
window.setTimeout(heartbeat, 1000 * 60);
|
||||
|
||||
function heartbeat(): void {
|
||||
if (stream != null && document.visibilityState === "visible") {
|
||||
stream.send("ping");
|
||||
}
|
||||
window.setTimeout(heartbeat, 1000 * 60);
|
||||
}
|
||||
|
|
|
@ -107,6 +107,12 @@ html, body {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.swiper {
|
||||
margin-inline: -24px !important;
|
||||
padding-inline: 24px !important;
|
||||
mask: linear-gradient(to right, transparent, black 24px calc(100% - 24px), transparent);
|
||||
-webkit-mask: linear-gradient(to right, transparent, black 24px calc(100% - 24px), transparent);
|
||||
}
|
||||
.swiper-slide {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
@ -287,6 +293,34 @@ hr {
|
|||
}
|
||||
}
|
||||
|
||||
._panel {
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
._margin {
|
||||
margin: var(--margin) 0;
|
||||
}
|
||||
|
||||
._gaps_m {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5em;
|
||||
}
|
||||
|
||||
._gaps_s {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
._gaps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--margin);
|
||||
}
|
||||
|
||||
._inputs {
|
||||
display: flex;
|
||||
margin: 32px 0;
|
||||
|
@ -320,7 +354,9 @@ hr {
|
|||
}
|
||||
|
||||
._block {
|
||||
@extend ._panel;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
overflow: visible;
|
||||
|
||||
& + ._block {
|
||||
margin-top: var(--margin);
|
||||
|
@ -615,6 +651,20 @@ hr {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
:not(.noGap):not(._block) > {
|
||||
.note-container, .cmuxhskf > section, ._gap > ._block, .noGap, .profile, .card {
|
||||
&:not(.caution)
|
||||
{
|
||||
box-shadow: 0 4px 25px rgba(0,0,0,0.04);
|
||||
border-radius: var(--radius);
|
||||
overflow: clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
.widgets ._panel {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
._zoom {
|
||||
transition-duration: 0.5s, 0.5s;
|
||||
transition-property: opacity, transform;
|
||||
|
@ -793,6 +843,27 @@ hr {
|
|||
}
|
||||
}
|
||||
|
||||
._otp_input {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 5px;
|
||||
margin: 1rem 7px auto;
|
||||
font-size: 20px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--accent);
|
||||
background-color: var(--accentedBg);
|
||||
color: var(--fg);
|
||||
text-align: center;
|
||||
}
|
||||
._otp_input.error {
|
||||
border-color: var(--error) !important;
|
||||
}
|
||||
._otp_input::-webkit-inner-spin-button,
|
||||
._otp_input::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media(prefers-reduced-motion: no-preference) {
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
|
|
|
@ -471,6 +471,21 @@ console.log(mainRouter.currentRoute.value.name);
|
|||
}
|
||||
&.wallpaper {
|
||||
background: var(--wallpaperOverlay);
|
||||
|
||||
:deep(.sidebar .middle) {
|
||||
position: relative;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -10px 10px;
|
||||
background: var(--bg);
|
||||
border-radius: calc((2.85rem / 2) + 5px);
|
||||
opacity: 1;
|
||||
}
|
||||
> ._button:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.centered {
|
||||
|
@ -547,7 +562,7 @@ console.log(mainRouter.currentRoute.value.name);
|
|||
&.wallpaper {
|
||||
.contents {
|
||||
background: var(--acrylicBg) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
backdrop-filter: var(--blur, blur(12px));
|
||||
}
|
||||
:deep(.tl),
|
||||
:deep(.notes) {
|
||||
|
|
1746
pnpm-lock.yaml
1746
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -5,7 +5,7 @@ body:
|
|||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to make Calckey better!
|
||||
Thanks for taking the time to make Calckey better! It's not required, but please consider using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) when making your commits. If you use VSCode, please use the [Conventional Commits extension](https://marketplace.visualstudio.com/items?itemName=vivaxy.vscode-conventional-commits).
|
||||
- type: textarea
|
||||
id: about
|
||||
attributes:
|
||||
|
|
|
@ -18,4 +18,9 @@ const execa = require('execa');
|
|||
cwd: __dirname + '/../',
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
execa('cargo', ['clean'], {
|
||||
cwd: __dirname + '/../packages/backend/native-utils',
|
||||
stdio: 'inherit'
|
||||
});
|
||||
})();
|
||||
|
|
Loading…
Reference in a new issue