Merge branch 'develop' of https://codeberg.org/calckey/calckey into logged-out

This commit is contained in:
Freeplay 2023-06-17 12:46:08 -04:00
commit af38bc540d
91 changed files with 2281 additions and 1820 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -1049,8 +1049,8 @@ _tutorial:
step6_4: "Now go, explore, and have fun!"
_2fa:
alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين."
registerDevice: "سجّل جهازًا جديدًا"
registerKey: "تسجيل مفتاح أمان جديد"
registerTOTP: "سجّل جهازًا جديدًا"
registerSecurityKey: "تسجيل مفتاح أمان جديد"
step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})."
step2: "امسح رمز الاستجابة السريعة الموجد على الشاشة."
step3: "أدخل الرمز الموجود في تطبيقك لإكمال التثبيت."

View file

@ -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 লিখুন:"

View file

@ -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: Laplicació dautenticació 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}'

View file

@ -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í"

View file

@ -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

View file

@ -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}"

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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
),
{},
);

View file

@ -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"

View file

@ -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 後援者

View file

@ -1179,8 +1179,8 @@ _time:
day: "일"
_2fa:
alreadyRegistered: "이미 설정이 완료되었습니다."
registerDevice: "디바이스 등록"
registerKey: "키를 등록"
registerTOTP: "디바이스 등록"
registerSecurityKey: "키를 등록"
step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다."
step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다."
step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:"

View file

@ -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
View 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ê

View file

@ -1249,8 +1249,8 @@ _tutorial:
step6_4: "Теперь идите, изучайте и развлекайтесь!"
_2fa:
alreadyRegistered: "Двухфакторная аутентификация уже настроена."
registerDevice: "Зарегистрируйте ваше устройство"
registerKey: "Зарегистрировать ключ"
registerTOTP: "Зарегистрируйте ваше устройство"
registerSecurityKey: "Зарегистрировать ключ"
step1: "Прежде всего, установите на устройство приложение для аутентификации, например,\
\ {a} или {b}."
step2: "Далее отсканируйте отображаемый QR-код при помощи приложения."

View file

@ -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:"

View file

@ -959,7 +959,7 @@ _tutorial:
step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але цей працює! Це трохи складно, але ви швидко розберетеся"
step6_4: "Тепер ідіть, вивчайте і розважайтеся!"
_2fa:
registerKey: "Зареєструвати новий ключ безпеки"
registerSecurityKey: "Зареєструвати новий ключ безпеки"
_permissions:
"read:account": "Переглядати дані профілю"
"write:account": "Змінити дані акаунту"

View file

@ -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:"

View file

@ -1210,8 +1210,8 @@ _tutorial:
step6_4: "现在去学习并享受乐趣!"
_2fa:
alreadyRegistered: "此设备已被注册"
registerDevice: "注册设备"
registerKey: "注册密钥"
registerTOTP: "注册设备"
registerSecurityKey: "注册密钥"
step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。"
step2: "然后,扫描屏幕上显示的二维码。"
step2Url: "在桌面应用程序中输入以下URL"

View file

@ -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: 跳到上一個

View file

@ -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",

View file

@ -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`,
);
}
}

View file

@ -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

View 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")]

View file

@ -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",

View file

@ -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" : "";

View file

@ -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,

View file

@ -567,7 +567,6 @@ export const UserRepository = db.getRepository(User).extend({
mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: profile!.mutingNotificationTypes,
emailNotificationTypes: profile!.emailNotificationTypes,
showTimelineReplies: user.showTimelineReplies || falsy,
}
: {}),

View file

@ -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

View file

@ -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);

View file

@ -279,7 +279,6 @@ export async function createPerson(
tags,
isBot,
isCat: (person as any).isCat === true,
showTimelineReplies: false,
}),
)) as IRemoteUser;

View file

@ -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");
}),
);
}),

View file

@ -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],

View file

@ -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);
});

View file

@ -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",

View file

@ -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);
});

View file

@ -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,

View file

@ -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,

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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")

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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 :)",

View file

@ -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 {

View file

@ -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 (

View file

@ -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 (

View file

@ -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 (

View file

@ -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 (

View file

@ -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 (

View file

@ -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", {

View file

@ -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

View file

@ -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 }) },

View file

@ -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 };

View file

@ -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"
}
}

View file

@ -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";

View file

@ -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) {

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -219,6 +219,7 @@
<MkFollowButton
:user="notification.user"
:full="true"
:hideMenu="true"
/></div
></span>
<span

View file

@ -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);

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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>

View file

@ -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[];

View file

@ -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>

View file

@ -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;
});

View 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>

View file

@ -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";
// : meUpdatedrefreshAccount
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>

View file

@ -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);

View file

@ -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 {

View file

@ -115,6 +115,7 @@ const defaultStoreSaveKeys: (keyof (typeof defaultStore)["state"])[] = [
"enableCustomKaTeXMacro",
"enableEmojiReactions",
"showEmojisInReactionNotifications",
"showTimelineReplies",
];
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
"lightTheme",

View file

@ -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;

View file

@ -354,7 +354,6 @@ onMounted(() => {
> .tl {
background: none;
border-radius: var(--radius);
overflow: clip;
}
}
</style>

View file

@ -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;

View file

@ -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,
});
}

View file

@ -330,6 +330,10 @@ export const defaultStore = markRaw(
where: "account",
default: true,
},
showTimelineReplies: {
where: "device",
default: true,
}
}),
);

View file

@ -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);
}

View file

@ -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 {

View file

@ -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) {

File diff suppressed because it is too large Load diff

View file

@ -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:

View file

@ -18,4 +18,9 @@ const execa = require('execa');
cwd: __dirname + '/../',
stdio: 'inherit'
});
execa('cargo', ['clean'], {
cwd: __dirname + '/../packages/backend/native-utils',
stdio: 'inherit'
});
})();