Merge remote-tracking branch 'upstream/develop' into develop

This commit is contained in:
sim1222 2023-02-22 18:12:25 +09:00
commit ca9e0387cd
No known key found for this signature in database
GPG Key ID: 04EF48D01BEB0298
125 changed files with 2677 additions and 1534 deletions

View File

@ -37,7 +37,6 @@ jobs:
- frontend - frontend
- sw - sw
lint: lint:
- typecheck
- eslint - eslint
steps: steps:
- uses: actions/checkout@v3.3.0 - uses: actions/checkout@v3.3.0

View File

@ -8,13 +8,27 @@
You should also include the user name that made the change. You should also include the user name that made the change.
--> -->
## 13.x.x (unreleased) ## 13.7.0 (2023/02/22)
### Changes
- チャット機能が削除されました
### Improvements ### Improvements
- Server: URLプレビューsummalyはプロキシを通すように - Server: URLプレビューsummalyはプロキシを通すように
- Client: 2FA設定のUIをまともにした
- セキュリティキーの名前を変更できるように
- enhance(client): add quiz preset for play
- 広告開始時期を設定できるように
- みつけるで公開ロール一覧とそのメンバーを閲覧できるように
- enhance(client): MFMのx3, x4が含まれていたらートをたたむように
- enhance(client): make possible to reload page of window
### Bugfixes ### Bugfixes
- - ユーザー検索ダイアログでローカルユーザーを絞って検索できない問題を修正
- fix(client): MkHeader及びデッキのカラムでチャンネル一覧を選択したとき、最大5個までしか表示されない
- 管理画面の広告を10個以上見えるように
- Moderation note が保存できない
- ユーザーのハッシュタグ検索が機能していないのを修正
## 13.6.1 (2023/02/12) ## 13.6.1 (2023/02/12)

View File

@ -5,9 +5,9 @@ Also, the later tasks are more indefinite and are subject to change as developme
## (1) Improve maintainability \<current phase\> ## (1) Improve maintainability \<current phase\>
This is the phase we are at now. We need to make a high-maintenance environment that can withstand future development. This is the phase we are at now. We need to make a high-maintenance environment that can withstand future development.
- Make the number of type errors zero (backend) - ~~Make the number of type errors zero (backend)~~ → Done ✔️
- Improve CI - Improve CI
- Fix tests - ~~Fix tests~~ → Done ✔️
- Fix random test failures - https://github.com/misskey-dev/misskey/issues/7985 and https://github.com/misskey-dev/misskey/issues/7986 - Fix random test failures - https://github.com/misskey-dev/misskey/issues/7985 and https://github.com/misskey-dev/misskey/issues/7986
- Add more tests - Add more tests
- ~~May need to implement a mechanism that allows for DI~~ → Done ✔️ - ~~May need to implement a mechanism that allows for DI~~ → Done ✔️

5
codecov.yml Normal file
View File

@ -0,0 +1,5 @@
coverage:
status:
project:
default:
only_pulls: true

View File

@ -103,6 +103,8 @@ renoted: "Renote getätigt."
cantRenote: "Renote dieses Beitrags nicht möglich." cantRenote: "Renote dieses Beitrags nicht möglich."
cantReRenote: "Renote einer Renote nicht möglich." cantReRenote: "Renote einer Renote nicht möglich."
quote: "Zitieren" quote: "Zitieren"
inChannelRenote: "Kanal-interner Renote"
inChannelQuote: "Kanal-internes Zitat"
pinnedNote: "Angeheftete Notiz" pinnedNote: "Angeheftete Notiz"
pinned: "Angeheftet" pinned: "Angeheftet"
you: "Du" you: "Du"
@ -467,6 +469,8 @@ youHaveNoGroups: "Keine Gruppen vorhanden"
joinOrCreateGroup: "Lass dich zu einer Gruppe einladen oder erstelle deine eigene." joinOrCreateGroup: "Lass dich zu einer Gruppe einladen oder erstelle deine eigene."
noHistory: "Kein Verlauf gefunden" noHistory: "Kein Verlauf gefunden"
signinHistory: "Anmeldungsverlauf" signinHistory: "Anmeldungsverlauf"
enableAdvancedMfm: "Erweitertes MFM aktivieren"
enableAnimatedMfm: "Animiertes MFM aktivieren"
doing: "In Bearbeitung …" doing: "In Bearbeitung …"
category: "Kategorie" category: "Kategorie"
tags: "Schlagwörter" tags: "Schlagwörter"
@ -945,6 +949,14 @@ selectFromPresets: "Aus Vorlagen wählen"
achievements: "Errungenschaften" achievements: "Errungenschaften"
gotInvalidResponseError: "Ungültige Antwort des Servers" gotInvalidResponseError: "Ungültige Antwort des Servers"
gotInvalidResponseErrorDescription: "Eventuell ist der Server momentan nicht erreichbar oder untergeht Wartungsarbeiten. Bitte versuche es später noch einmal." gotInvalidResponseErrorDescription: "Eventuell ist der Server momentan nicht erreichbar oder untergeht Wartungsarbeiten. Bitte versuche es später noch einmal."
thisPostMayBeAnnoying: "Dieser Beitrag stört eventuell andere Benutzer."
thisPostMayBeAnnoyingHome: "Zur Startseite schicken"
thisPostMayBeAnnoyingCancel: "Abbrechen"
thisPostMayBeAnnoyingIgnore: "Trotzdem schicken"
collapseRenotes: "Bereits gesehene Renotes verkürzt anzeigen"
internalServerError: "Serverinterner Fehler"
internalServerErrorDescription: "Im Server ist ein unerwarteter Fehler aufgetreten."
copyErrorInfo: "Fehlerdetails kopieren"
_achievements: _achievements:
earnedAt: "Freigeschaltet am" earnedAt: "Freigeschaltet am"
_types: _types:

View File

@ -103,6 +103,8 @@ renoted: "Renoted."
cantRenote: "This post can't be renoted." cantRenote: "This post can't be renoted."
cantReRenote: "A renote can't be renoted." cantReRenote: "A renote can't be renoted."
quote: "Quote" quote: "Quote"
inChannelRenote: "Channel-only Renote"
inChannelQuote: "Channel-only Quote"
pinnedNote: "Pinned note" pinnedNote: "Pinned note"
pinned: "Pin to profile" pinned: "Pin to profile"
you: "You" you: "You"
@ -468,7 +470,7 @@ joinOrCreateGroup: "Get invited to a group or create your own."
noHistory: "No history available" noHistory: "No history available"
signinHistory: "Login history" signinHistory: "Login history"
enableAdvancedMfm: "Enable advanced MFM" enableAdvancedMfm: "Enable advanced MFM"
enableAnimatedMfm: "Enable MFM with animation" enableAnimatedMfm: "Enable animated MFM"
doing: "Processing..." doing: "Processing..."
category: "Category" category: "Category"
tags: "Tags" tags: "Tags"
@ -951,6 +953,10 @@ thisPostMayBeAnnoying: "This note may annoy others."
thisPostMayBeAnnoyingHome: "Post to home timeline" thisPostMayBeAnnoyingHome: "Post to home timeline"
thisPostMayBeAnnoyingCancel: "Cancel" thisPostMayBeAnnoyingCancel: "Cancel"
thisPostMayBeAnnoyingIgnore: "Post anyway" thisPostMayBeAnnoyingIgnore: "Post anyway"
collapseRenotes: "Collapse renotes you've already seen"
internalServerError: "Internal Server Error"
internalServerErrorDescription: "The server has run into an unexpected error."
copyErrorInfo: "Copy error details"
_achievements: _achievements:
earnedAt: "Unlocked at" earnedAt: "Unlocked at"
_types: _types:

View File

@ -84,12 +84,12 @@ error: "Errore"
somethingHappened: "Si è verificato un problema" somethingHappened: "Si è verificato un problema"
retry: "Riprova" retry: "Riprova"
pageLoadError: "Caricamento pagina non riuscito. " pageLoadError: "Caricamento pagina non riuscito. "
pageLoadErrorDescription: "Questo viene normalmente causato dalla rete o dalla cache del browser. Si prega di pulire la cache, o di attendere e riprovare più tardi." pageLoadErrorDescription: "Questo problema viene normalmente causato da errori di rete o dalla cache del browser. Si prega di pulire la cache, o di attendere e riprovare più tardi."
serverIsDead: "Il server non risponde. Si prega di attendere e riprovare più tardi." serverIsDead: "Il server non risponde. Si prega di attendere e riprovare più tardi."
youShouldUpgradeClient: "Per visualizzare la pagina è necessario aggiornare il client alla nuova versione e ricaricare." youShouldUpgradeClient: "Per visualizzare la pagina è necessario aggiornare il client alla nuova versione e ricaricare."
enterListName: "Nome della lista" enterListName: "Nome della lista"
privacy: "Privacy" privacy: "Privacy"
makeFollowManuallyApprove: "Richiedi di approvare i follower manualmente" makeFollowManuallyApprove: "Approva i follower manualmente"
defaultNoteVisibility: "Privacy predefinita delle note" defaultNoteVisibility: "Privacy predefinita delle note"
follow: "Segui" follow: "Segui"
followRequest: "Richiesta di follow" followRequest: "Richiesta di follow"
@ -103,6 +103,8 @@ renoted: "Rinotato!"
cantRenote: "È impossibile rinotare questa nota." cantRenote: "È impossibile rinotare questa nota."
cantReRenote: "È impossibile rinotare una Rinota." cantReRenote: "È impossibile rinotare una Rinota."
quote: "Cita" quote: "Cita"
inChannelRenote: "Rinota nel canale"
inChannelQuote: "Cita nel canale"
pinnedNote: "Nota fissata" pinnedNote: "Nota fissata"
pinned: "Fissa sul profilo" pinned: "Fissa sul profilo"
you: "Tu" you: "Tu"
@ -129,6 +131,7 @@ unblockConfirm: "Vuoi davvero sbloccare il profilo?"
suspendConfirm: "Vuoi sospendere questo profilo?" suspendConfirm: "Vuoi sospendere questo profilo?"
unsuspendConfirm: "Vuoi revocare la sospensione si questo profilo?" unsuspendConfirm: "Vuoi revocare la sospensione si questo profilo?"
selectList: "Seleziona una lista" selectList: "Seleziona una lista"
selectChannel: "Seleziona canale"
selectAntenna: "Scegli un'antenna" selectAntenna: "Scegli un'antenna"
selectWidget: "Seleziona il riquadro" selectWidget: "Seleziona il riquadro"
editWidgets: "Modifica i riquadri" editWidgets: "Modifica i riquadri"
@ -256,6 +259,8 @@ noMoreHistory: "Non c'è più cronologia da visualizzare"
startMessaging: "Nuovo messaggio" startMessaging: "Nuovo messaggio"
nUsersRead: "Letto da {n} persone" nUsersRead: "Letto da {n} persone"
agreeTo: "Sono d'accordo con {0}" agreeTo: "Sono d'accordo con {0}"
agreeBelow: "Accetto quanto riportato sotto"
basicNotesBeforeCreateAccount: "Note importanti"
tos: "Termini di servizio" tos: "Termini di servizio"
start: "Inizia!" start: "Inizia!"
home: "Home" home: "Home"
@ -464,6 +469,8 @@ youHaveNoGroups: "Nessun gruppo"
joinOrCreateGroup: "Puoi creare il tuo gruppo o essere invitat@ a gruppi che già esistono." joinOrCreateGroup: "Puoi creare il tuo gruppo o essere invitat@ a gruppi che già esistono."
noHistory: "Nessuna cronologia" noHistory: "Nessuna cronologia"
signinHistory: "Storico degli accessi al profilo" signinHistory: "Storico degli accessi al profilo"
enableAdvancedMfm: "Attiva MFM avanzati"
enableAnimatedMfm: "Attiva MFM animati"
doing: "In corso..." doing: "In corso..."
category: "Categoria" category: "Categoria"
tags: "Tag" tags: "Tag"
@ -860,6 +867,8 @@ failedToFetchAccountInformation: "Impossibile recuperare le informazioni sul pro
rateLimitExceeded: "Superato il limite di velocità." rateLimitExceeded: "Superato il limite di velocità."
cropImage: "Ritaglio dell'immagine" cropImage: "Ritaglio dell'immagine"
cropImageAsk: "Si desidera ritagliare l'immagine?" cropImageAsk: "Si desidera ritagliare l'immagine?"
cropYes: "Ritaglia"
cropNo: "Non ritagliare"
file: "Allegati" file: "Allegati"
recentNHours: "Ultime {n} ore" recentNHours: "Ultime {n} ore"
recentNDays: "Ultimi {n} giorni" recentNDays: "Ultimi {n} giorni"
@ -938,6 +947,16 @@ cannotPerformTemporaryDescription: "L'attività non può essere svolta, poiché
preset: "Preimpostato" preset: "Preimpostato"
selectFromPresets: "Seleziona preimpostato" selectFromPresets: "Seleziona preimpostato"
achievements: "Obiettivi raggiunti" achievements: "Obiettivi raggiunti"
gotInvalidResponseError: "Risposta del server non valida"
gotInvalidResponseErrorDescription: "Il server potrebbe essere irraggiungibile o in manutenzione. Riprova più tardi."
thisPostMayBeAnnoying: "Questa nota potrebbe essere offensiva"
thisPostMayBeAnnoyingHome: "Pubblica sulla timeline principale"
thisPostMayBeAnnoyingCancel: "Annulla"
thisPostMayBeAnnoyingIgnore: "Pubblica lo stesso"
collapseRenotes: "Comprimi i Rinota già letti"
internalServerError: "Errore interno del server"
internalServerErrorDescription: "Si è verificato un errore imprevisto all'interno del server"
copyErrorInfo: "Copia le informazioni sull'errore"
_achievements: _achievements:
earnedAt: "Data di conseguimento" earnedAt: "Data di conseguimento"
_types: _types:
@ -1526,12 +1545,15 @@ _permissions:
"read:gallery-likes": "Visualizza i contenuti della galleria." "read:gallery-likes": "Visualizza i contenuti della galleria."
"write:gallery-likes": "Manipolazione dei \"Mi piace\" della galleria." "write:gallery-likes": "Manipolazione dei \"Mi piace\" della galleria."
_auth: _auth:
shareAccessTitle: "Permessi dell'applicazione"
shareAccess: "Vuoi autorizzare {name} ad accedere al tuo profilo?" shareAccess: "Vuoi autorizzare {name} ad accedere al tuo profilo?"
shareAccessAsk: "Vuoi autorizzare questa App ad accedere al tuo profilo?" shareAccessAsk: "Vuoi autorizzare questa App ad accedere al tuo profilo?"
permission: "{name} richiede i permessi seguenti"
permissionAsk: "Questa app richiede le seguenti autorizzazioni:" permissionAsk: "Questa app richiede le seguenti autorizzazioni:"
pleaseGoBack: "Si prega di ritornare sulla app" pleaseGoBack: "Si prega di ritornare sulla app"
callback: "Ritornando sulla app" callback: "Ritornando sulla app"
denied: "Accesso negato" denied: "Accesso negato"
pleaseLogin: "Per favore accedi al tuo account per cambiare i permessi dell'applicazione"
_antennaSources: _antennaSources:
all: "Tutte le note" all: "Tutte le note"
homeTimeline: "Note dagli utenti che segui" homeTimeline: "Note dagli utenti che segui"

View File

@ -394,17 +394,20 @@ userList: "リスト"
about: "情報" about: "情報"
aboutMisskey: "Misskeyについて" aboutMisskey: "Misskeyについて"
administrator: "管理者" administrator: "管理者"
token: "トークン" token: "確認コード"
twoStepAuthentication: "二段階認証" 2fa: "二要素認証"
totp: "認証アプリ"
totpDescription: "認証アプリを使ってワンタイムパスワードを入力"
moderator: "モデレーター" moderator: "モデレーター"
moderation: "モデレーション" moderation: "モデレーション"
nUsersMentioned: "{n}人が投稿" nUsersMentioned: "{n}人が投稿"
securityKeyAndPasskey: "セキュリティキー・パスキー"
securityKey: "セキュリティキー" securityKey: "セキュリティキー"
securityKeyName: "キーの名前"
registerSecurityKey: "セキュリティキーを登録する"
lastUsed: "最後の使用" lastUsed: "最後の使用"
lastUsedAt: "最後の使用: {t}"
unregister: "登録を解除" unregister: "登録を解除"
passwordLessLogin: "パスワード無しログイン" passwordLessLogin: "パスワードレスログイン"
passwordLessLoginDescription: "パスワードを使用せず、セキュリティキーやパスキーなどのみでログインします"
resetPassword: "パスワードをリセット" resetPassword: "パスワードをリセット"
newPasswordIs: "新しいパスワードは「{password}」です" newPasswordIs: "新しいパスワードは「{password}」です"
reduceUiAnimation: "UIのアニメーションを減らす" reduceUiAnimation: "UIのアニメーションを減らす"
@ -449,7 +452,6 @@ passwordMatched: "一致しました"
passwordNotMatched: "一致していません" passwordNotMatched: "一致していません"
signinWith: "{x}でログイン" signinWith: "{x}でログイン"
signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
tapSecurityKey: "セキュリティキーにタッチ"
or: "もしくは" or: "もしくは"
language: "言語" language: "言語"
uiLanguage: "UIの表示言語" uiLanguage: "UIの表示言語"
@ -946,6 +948,9 @@ collapseRenotes: "見たことのあるRenoteを省略して表示"
internalServerError: "サーバー内部エラー" internalServerError: "サーバー内部エラー"
internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。" internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。"
copyErrorInfo: "エラー情報をコピー" copyErrorInfo: "エラー情報をコピー"
joinThisServer: "このサーバーに登録する"
exploreOtherServers: "他のサーバーを探す"
letsLookAtTimeline: "タイムラインを見てみる"
_achievements: _achievements:
earnedAt: "獲得日時" earnedAt: "獲得日時"
@ -1529,14 +1534,29 @@ _tutorial:
_2fa: _2fa:
alreadyRegistered: "既に設定は完了しています。" alreadyRegistered: "既に設定は完了しています。"
registerDevice: "デバイスを登録" registerTOTP: "認証アプリの設定を開始"
registerKey: "キーを登録" passwordToTOTP: "パスワードを入力してください"
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。" step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
step2: "次に、表示されているQRコードをアプリでスキャンします。" step2: "次に、表示されているQRコードをアプリでスキャンします。"
step2Url: "デスクトップアプリでは次のURLを入力します:" step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
step3: "アプリに表示されているトークンを入力して完了です。" step2Url: "デスクトップアプリでは次のURIを入力します:"
step4: "これからログインするときも、同じようにトークンを入力します。" step3Title: "確認コードを入力"
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。" step3: "アプリに表示されている確認コード(トークン)を入力して完了です。"
step4: "これからログインするときも、同じように確認コードを入力します。"
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。"
registerSecurityKey: "セキュリティキー・パスキーを登録する"
securityKeyName: "キーの名前を入力"
tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"
removeKey: "セキュリティキーを削除"
removeKeyConfirm: "{name}を削除しますか?"
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
renewTOTP: "認証アプリを再設定"
renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります"
renewTOTPOk: "再設定する"
renewTOTPCancel: "やめておく"
_permissions: _permissions:
"read:account": "アカウントの情報を見る" "read:account": "アカウントの情報を見る"
@ -1871,3 +1891,7 @@ _deck:
channel: "チャンネル" channel: "チャンネル"
mentions: "あなた宛て" mentions: "あなた宛て"
direct: "ダイレクト" direct: "ダイレクト"
_dialog:
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}"

View File

@ -103,6 +103,8 @@ renoted: "Renoteしたで。"
cantRenote: "この投稿はRenoteできへんらしい。" cantRenote: "この投稿はRenoteできへんらしい。"
cantReRenote: "Renote自体はRenoteできへんで。" cantReRenote: "Renote自体はRenoteできへんで。"
quote: "引用" quote: "引用"
inChannelRenote: "チャンネル内Renote"
inChannelQuote: "チャンネル内引用"
pinnedNote: "ピン留めされとるノート" pinnedNote: "ピン留めされとるノート"
pinned: "ピン留めしとく" pinned: "ピン留めしとく"
you: "あんた" you: "あんた"
@ -948,35 +950,168 @@ achievements: "実績"
gotInvalidResponseError: "サーバー黙っとるわ、知らんけど" gotInvalidResponseError: "サーバー黙っとるわ、知らんけど"
gotInvalidResponseErrorDescription: "サーバーいま日曜日。またきて月曜日。" gotInvalidResponseErrorDescription: "サーバーいま日曜日。またきて月曜日。"
thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。" thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。"
thisPostMayBeAnnoyingHome: "ホームに投稿"
thisPostMayBeAnnoyingCancel: "やめとく"
thisPostMayBeAnnoyingIgnore: "このまま投稿"
collapseRenotes: "見たことあるRenoteは省略やで" collapseRenotes: "見たことあるRenoteは省略やで"
internalServerError: "サーバー内部エラー"
internalServerErrorDescription: "サーバー内部でよう分からんエラーやわ"
copyErrorInfo: "エラー情報をコピー"
_achievements: _achievements:
earnedAt: "貰った日ぃ" earnedAt: "貰った日ぃ"
_types: _types:
_notes1: _notes1:
title: "まいど!" title: "まいど!"
description: "初めてノート投稿したった" description: "初めてノート投稿したった"
flavor: "Misskeyを楽しんでな"
_notes10: _notes10:
title: "ノートの天保山" title: "ノートの天保山"
description: "ートを10回投稿した"
_notes100: _notes100:
title: "ノートの真田山" title: "ノートの真田山"
description: "ートを100回投稿した"
_notes500: _notes500:
title: "ノートの生駒山" title: "ノートの生駒山"
description: "ートを500回投稿した"
_notes1000:
title: "ノートの山"
description: "ートを1,000回投稿した"
_notes5000: _notes5000:
title: "箕面の滝からノート" title: "箕面の滝からノート"
description: "ートを5,000回投稿した"
_notes10000:
title: "スーパーノート"
description: "ートを10,000回投稿した"
_notes20000:
title: "ニードモアノート"
description: "ートを20,000回投稿した"
_notes30000:
title: "ノートノートノート"
description: "ートを30,000回投稿した"
_notes40000:
title: "ノート工場"
description: "ートを40,000回投稿した"
_notes50000:
title: "ノートの惑星"
description: "ートを50,000回投稿した"
_notes60000:
title: "ノートクエーサー"
description: "ートを60,000回投稿した"
_notes70000:
title: "ブラックノートホール"
description: "ートを70,000回投稿した"
_notes80000:
title: "ノートギャラクシー"
description: "ートを80,000回投稿した"
_notes90000:
title: "ノートバース"
description: "ートを90,000回投稿した"
_notes100000:
title: "ALL YOUR NOTE ARE BELONG TO US"
description: "ートを100,000回投稿した"
flavor: "そんなに書くことあるんか?"
_login3: _login3:
title: "ビギナーⅠ"
description: "通算ログイン日数が3日"
flavor: "今日からワシはミスキストやで" flavor: "今日からワシはミスキストやで"
_login7:
title: "ビギナーⅡ"
description: "通算ログイン日数が7日"
flavor: "慣れてきたんちゃう?"
_login15:
title: "ビギナーⅢ"
description: "通算ログイン日数が15日"
_login30:
title: "ミスキストⅠ"
description: "通算ログイン日数が30日"
_login60:
title: "ミスキストⅡ"
description: "通算ログイン日数が60日"
_login100:
title: "ミスキストⅢ"
description: "通算ログイン日数が100日"
flavor: "そのユーザー、ミスキストにつき"
_login200:
title: "常連Ⅰ"
_followers500:
title: "基地局"
description: "フォロワーが500人を超した"
_followers1000:
title: "インフルエンサー"
description: "フォロワーが1,000人を超した"
_collectAchievements30:
title: "実績コレクター"
description: "実績を30個以上獲得した"
_viewAchievements3min:
title: "実績好き"
description: "実績一覧を3分以上眺め続けた"
_iLoveMisskey: _iLoveMisskey:
title: "Misskey好きやねん" title: "Misskey好きやねん"
description: "\"I ❤ #Misskey\"を投稿した"
flavor: "Misskeyを使ってくれてありがとうな by 開発チーム"
_foundTreasure: _foundTreasure:
title: "なんでも鑑定団" title: "なんでも鑑定団"
description: "隠されたお宝を発見した"
_client30min: _client30min:
title: "ねんね" title: "ねんね"
description: "クライアントを起動してから30分以上経過した"
_noteDeletedWithin1min: _noteDeletedWithin1min:
title: "*おおっと*" title: "*おおっと*"
description: "投稿してから1分以内にその投稿を消した"
_postedAtLateNight:
title: "夜行性"
description: "深夜にノートを投稿した"
flavor: "そろそろ寝よか"
_postedAt0min0sec:
title: "時報"
description: "0分0秒にートを投稿した"
flavor: "ポッ ポッ ポッ ピーン"
_selfQuote:
title: "自己言及"
description: "自分のノートを引用した"
_htl20npm:
title: "流れるTL"
description: "ホームタイムラインの流速が20npmを超す"
_viewInstanceChart:
title: "アナリスト"
description: "インスタンスのチャートを表示した"
_outputHelloWorldOnScratchpad:
title: "Hello, world!"
description: "スクラッチパッドで hello worldを出力した"
_open3windows: _open3windows:
title: "マド開けすぎ" title: "マド開けすぎ"
description: "ウィンドウを3つ以上開いた状態にした"
_driveFolderCircularReference: _driveFolderCircularReference:
title: "環状線" title: "環状線"
description: "ドライブのフォルダを再帰的な入れ子にしようとした"
_reactWithoutRead:
title: "ちゃんと読んだんか?"
description: "100文字以上のテキストを含むートに投稿されてから3秒以内にリアクションした"
_clickedClickHere:
title: "ここをクリック"
description: "ここをクリックした"
_justPlainLucky:
title: "単なるラッキー"
description: "10秒ごとに0.005%の確率で獲得"
_setNameToSyuilo:
title: "神様コンプレックス"
description: "名前を syuilo に設定した"
_passedSinceAccountCreated1:
title: "一周年"
description: "アカウント作成から1年経過した"
_passedSinceAccountCreated2:
title: "二周年"
description: "アカウント作成から2年経過した"
_passedSinceAccountCreated3:
title: "三周年"
description: "アカウント作成から3年経過した"
_loggedInOnBirthday:
title: "ハッピーバースデー!"
description: "誕生日にログインした"
_loggedInOnNewYearsDay:
title: "あけましておめでとうございます!"
description: "元旦にログインした"
flavor: "今年も弊インスタンスをよろしくお願いします"
_role: _role:
new: "ロールの作成" new: "ロールの作成"
edit: "ロールの編集" edit: "ロールの編集"

View File

@ -1113,6 +1113,8 @@ _achievements:
_loggedInOnNewYearsDay: _loggedInOnNewYearsDay:
title: "З Новим роком!" title: "З Новим роком!"
description: "Увійшли в перший день року" description: "Увійшли в перший день року"
_cookieClicked:
flavor: "Чекайте, це вірний сайт?"
_brainDiver: _brainDiver:
title: "Brain Diver" title: "Brain Diver"
description: "Відправити посилання на \"Brain Diver\"" description: "Відправити посилання на \"Brain Diver\""

View File

@ -103,6 +103,8 @@ renoted: "已转发。"
cantRenote: "该帖无法转发。" cantRenote: "该帖无法转发。"
cantReRenote: "转发无法被再次转发。" cantReRenote: "转发无法被再次转发。"
quote: "引用" quote: "引用"
inChannelRenote: "在频道内转发"
inChannelQuote: "在频道内引用"
pinnedNote: "已置顶的帖子" pinnedNote: "已置顶的帖子"
pinned: "置顶" pinned: "置顶"
you: "您" you: "您"
@ -951,6 +953,10 @@ thisPostMayBeAnnoying: "这个帖子可能会让其他人感到困扰。"
thisPostMayBeAnnoyingHome: "发到首页" thisPostMayBeAnnoyingHome: "发到首页"
thisPostMayBeAnnoyingCancel: "取消" thisPostMayBeAnnoyingCancel: "取消"
thisPostMayBeAnnoyingIgnore: "就这样发布" thisPostMayBeAnnoyingIgnore: "就这样发布"
collapseRenotes: "省略显示已经看过的转发内容"
internalServerError: "内部服务器错误"
internalServerErrorDescription: "内部服务器发生了预期外的错误"
copyErrorInfo: "复制错误信息"
_achievements: _achievements:
earnedAt: "达成时间" earnedAt: "达成时间"
_types: _types:

View File

@ -103,6 +103,8 @@ renoted: "轉傳成功"
cantRenote: "無法轉發此貼文。" cantRenote: "無法轉發此貼文。"
cantReRenote: "無法轉傳之前已經轉傳過的內容。" cantReRenote: "無法轉傳之前已經轉傳過的內容。"
quote: "引用" quote: "引用"
inChannelRenote: "在頻道內轉發"
inChannelQuote: "在頻道內引用"
pinnedNote: "已置頂的貼文" pinnedNote: "已置頂的貼文"
pinned: "置頂" pinned: "置頂"
you: "您" you: "您"
@ -952,6 +954,9 @@ thisPostMayBeAnnoyingHome: "發布到首頁"
thisPostMayBeAnnoyingCancel: "退出" thisPostMayBeAnnoyingCancel: "退出"
thisPostMayBeAnnoyingIgnore: "直接發布貼文" thisPostMayBeAnnoyingIgnore: "直接發布貼文"
collapseRenotes: "省略顯示已看過的轉發貼文" collapseRenotes: "省略顯示已看過的轉發貼文"
internalServerError: "內部伺服器錯誤"
internalServerErrorDescription: "內部伺服器發生了非預期的錯誤。"
copyErrorInfo: "複製錯誤資訊"
_achievements: _achievements:
earnedAt: "獲得日期" earnedAt: "獲得日期"
_types: _types:

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.6.1-simkey", "version": "13.7.0-simkey",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -54,12 +54,12 @@
"devDependencies": { "devDependencies": {
"@types/gulp": "4.0.10", "@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1", "@types/gulp-rename": "2.0.1",
"@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/eslint-plugin": "5.52.0",
"@typescript-eslint/parser": "5.51.0", "@typescript-eslint/parser": "5.52.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "12.5.1", "cypress": "12.6.0",
"eslint": "8.33.0", "eslint": "8.34.0",
"start-server-and-test": "1.15.3" "start-server-and-test": "1.15.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tensorflow/tfjs-core": "4.2.0" "@tensorflow/tfjs-core": "4.2.0"

View File

@ -25,30 +25,30 @@
"@tensorflow/tfjs-node": "4.2.0" "@tensorflow/tfjs-node": "4.2.0"
}, },
"dependencies": { "dependencies": {
"@bull-board/api": "4.11.1", "@bull-board/api": "4.12.1",
"@bull-board/fastify": "4.11.1", "@bull-board/fastify": "4.12.1",
"@bull-board/ui": "4.11.1", "@bull-board/ui": "4.12.1",
"@discordapp/twemoji": "14.0.2", "@discordapp/twemoji": "14.0.2",
"@fastify/accepts": "4.1.0", "@fastify/accepts": "4.1.0",
"@fastify/cookie": "8.3.0", "@fastify/cookie": "8.3.0",
"@fastify/cors": "8.2.0", "@fastify/cors": "8.2.0",
"@fastify/http-proxy": "8.4.0", "@fastify/http-proxy": "8.4.0",
"@fastify/multipart": "7.4.0", "@fastify/multipart": "7.4.1",
"@fastify/static": "6.8.0", "@fastify/static": "6.9.0",
"@fastify/view": "7.4.1", "@fastify/view": "7.4.1",
"@nestjs/common": "9.3.7", "@nestjs/common": "9.3.9",
"@nestjs/core": "9.3.7", "@nestjs/core": "9.3.9",
"@nestjs/testing": "9.3.7", "@nestjs/testing": "9.3.9",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.0.2", "@sinonjs/fake-timers": "10.0.2",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "5.3.1", "archiver": "5.3.1",
"autwh": "0.1.0", "autwh": "0.1.0",
"aws-sdk": "2.1295.0", "aws-sdk": "2.1318.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.4", "blurhash": "2.0.5",
"bull": "4.10.3", "bull": "4.10.4",
"cacheable-lookup": "6.1.0", "cacheable-lookup": "6.1.0",
"cbor": "8.1.0", "cbor": "8.1.0",
"chalk": "5.2.0", "chalk": "5.2.0",
@ -60,12 +60,13 @@
"date-fns": "2.29.3", "date-fns": "2.29.3",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1", "escape-regexp": "0.0.1",
"fastify": "4.12.0", "fastify": "4.13.0",
"feed": "4.2.2", "feed": "4.2.2",
"file-type": "18.2.0", "file-type": "18.2.1",
"fluent-ffmpeg": "2.1.2", "fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0", "form-data": "4.0.0",
"got": "12.5.3", "got": "12.5.3",
"happy-dom": "^8.7.0",
"hpagent": "1.2.0", "hpagent": "1.2.0",
"ioredis": "4.28.5", "ioredis": "4.28.5",
"ip-cidr": "3.1.0", "ip-cidr": "3.1.0",
@ -85,6 +86,7 @@
"nsfwjs": "2.4.2", "nsfwjs": "2.4.2",
"oauth": "0.10.0", "oauth": "0.10.0",
"os-utils": "0.0.14", "os-utils": "0.0.14",
"otpauth": "^9.0.2",
"parse5": "7.1.2", "parse5": "7.1.2",
"pg": "8.9.0", "pg": "8.9.0",
"private-ip": "3.0.0", "private-ip": "3.0.0",
@ -104,15 +106,14 @@
"rss-parser": "3.12.0", "rss-parser": "3.12.0",
"rxjs": "7.8.0", "rxjs": "7.8.0",
"s-age": "1.1.2", "s-age": "1.1.2",
"sanitize-html": "2.9.0", "sanitize-html": "2.10.0",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"semver": "7.3.8", "semver": "7.3.8",
"sharp": "0.31.3", "sharp": "0.31.3",
"speakeasy": "2.0.0",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"systeminformation": "5.17.8", "systeminformation": "5.17.9",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.1", "tmp": "0.2.1",
"tsc-alias": "1.8.2", "tsc-alias": "1.8.2",
@ -126,14 +127,14 @@
"vary": "1.1.2", "vary": "1.1.2",
"web-push": "3.5.0", "web-push": "3.5.0",
"websocket": "1.0.34", "websocket": "1.0.34",
"ws": "8.12.0", "ws": "8.12.1",
"xev": "3.0.2" "xev": "3.0.2"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "29.4.2", "@jest/globals": "29.4.3",
"@redocly/openapi-core": "1.0.0-beta.123", "@redocly/openapi-core": "1.0.0-beta.123",
"@swc/cli": "0.1.61", "@swc/cli": "0.1.62",
"@swc/core": "1.3.34", "@swc/core": "1.3.35",
"@swc/jest": "0.2.24", "@swc/jest": "0.2.24",
"@types/accepts": "1.3.5", "@types/accepts": "1.3.5",
"@types/archiver": "5.3.1", "@types/archiver": "5.3.1",
@ -151,7 +152,7 @@
"@types/jsonld": "1.5.8", "@types/jsonld": "1.5.8",
"@types/jsrsasign": "10.5.5", "@types/jsrsasign": "10.5.5",
"@types/mime-types": "2.1.1", "@types/mime-types": "2.1.1",
"@types/node": "18.13.0", "@types/node": "18.14.0",
"@types/node-fetch": "3.0.3", "@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/oauth": "0.9.1", "@types/oauth": "0.9.1",
@ -167,7 +168,6 @@
"@types/semver": "7.3.13", "@types/semver": "7.3.13",
"@types/sharp": "0.31.1", "@types/sharp": "0.31.1",
"@types/sinonjs__fake-timers": "8.1.2", "@types/sinonjs__fake-timers": "8.1.2",
"@types/speakeasy": "2.0.7",
"@types/tinycolor2": "1.4.3", "@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3", "@types/tmp": "0.2.3",
"@types/unzipper": "0.10.5", "@types/unzipper": "0.10.5",
@ -176,13 +176,13 @@
"@types/web-push": "3.3.2", "@types/web-push": "3.3.2",
"@types/websocket": "1.0.5", "@types/websocket": "1.0.5",
"@types/ws": "8.5.4", "@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/eslint-plugin": "5.52.0",
"@typescript-eslint/parser": "5.51.0", "@typescript-eslint/parser": "5.52.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.33.0", "eslint": "8.34.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",
"execa": "6.1.0", "execa": "6.1.0",
"jest": "29.4.2", "jest": "29.4.3",
"jest-mock": "29.4.2" "jest-mock": "29.4.3"
} }
} }

View File

@ -0,0 +1,8 @@
declare module 'redis-lock' {
import type Redis from 'ioredis';
type Lock = (lockName: string, timeout?: number, taskToPerform?: () => Promise<void>) => void;
function redisLock(client: Redis.Redis, retryDelay: number): Lock;
export = redisLock;
}

View File

@ -12,7 +12,7 @@ const retryDelay = 100;
@Injectable() @Injectable()
export class AppLockService { export class AppLockService {
private lock: (key: string, timeout?: number) => Promise<() => void>; private lock: (key: string, timeout?: number, _?: (() => Promise<void>) | undefined) => Promise<() => void>;
constructor( constructor(
@Inject(DI.redis) @Inject(DI.redis)

View File

@ -1,7 +1,7 @@
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5'; import * as parse5 from 'parse5';
import { JSDOM } from 'jsdom'; import { Window } from 'happy-dom';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js'; import { intersperse } from '@/misc/prelude/array.js';
@ -235,7 +235,7 @@ export class MfmService {
return null; return null;
} }
const { window } = new JSDOM(''); const { window } = new Window();
const doc = window.document; const doc = window.document;
@ -300,7 +300,7 @@ export class MfmService {
hashtag: (node) => { hashtag: (node) => {
const a = doc.createElement('a'); const a = doc.createElement('a');
a.href = `${this.config.url}/tags/${node.props.hashtag}`; a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`; a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag'); a.setAttribute('rel', 'tag');
return a; return a;
@ -326,7 +326,7 @@ export class MfmService {
link: (node) => { link: (node) => {
const a = doc.createElement('a'); const a = doc.createElement('a');
a.href = node.props.url; a.setAttribute('href', node.props.url);
appendChildren(node.children, a); appendChildren(node.children, a);
return a; return a;
}, },
@ -335,7 +335,7 @@ export class MfmService {
const a = doc.createElement('a'); const a = doc.createElement('a');
const { username, host, acct } = node.props; const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`; a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`);
a.className = 'u-url mention'; a.className = 'u-url mention';
a.textContent = acct; a.textContent = acct;
return a; return a;
@ -360,14 +360,14 @@ export class MfmService {
url: (node) => { url: (node) => {
const a = doc.createElement('a'); const a = doc.createElement('a');
a.href = node.props.url; a.setAttribute('href', node.props.url);
a.textContent = node.props.url; a.textContent = node.props.url;
return a; return a;
}, },
search: (node) => { search: (node) => {
const a = doc.createElement('a'); const a = doc.createElement('a');
a.href = `https://www.google.com/search?q=${node.props.query}`; a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
a.textContent = node.props.content; a.textContent = node.props.content;
return a; return a;
}, },

View File

@ -9,7 +9,7 @@ import { MetaService } from '@/core/MetaService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
// Defined also packages/sw/types.ts#L13 // Defined also packages/sw/types.ts#L13
type pushNotificationsTypes = { type PushNotificationsTypes = {
'notification': Packed<'Notification'>; 'notification': Packed<'Notification'>;
'unreadAntennaNote': { 'unreadAntennaNote': {
antenna: { id: string, name: string }; antenna: { id: string, name: string };
@ -22,7 +22,7 @@ type pushNotificationsTypes = {
}; };
// Reduce length because push message servers have character limits // Reduce length because push message servers have character limits
function truncateBody<T extends keyof pushNotificationsTypes>(type: T, body: pushNotificationsTypes[T]): pushNotificationsTypes[T] { function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: PushNotificationsTypes[T]): PushNotificationsTypes[T] {
if (typeof body !== 'object') return body; if (typeof body !== 'object') return body;
return { return {
@ -56,7 +56,7 @@ export class PushNotificationService {
} }
@bindThis @bindThis
public async pushNotification<T extends keyof pushNotificationsTypes>(userId: string, type: T, body: pushNotificationsTypes[T]) { public async pushNotification<T extends keyof PushNotificationsTypes>(userId: string, type: T, body: PushNotificationsTypes[T]) {
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;

View File

@ -452,7 +452,7 @@ export class ApInboxService {
const user = await this.usersRepository.findOneByOrFail({ id: actor.id }); const user = await this.usersRepository.findOneByOrFail({ id: actor.id });
if (user.isDeleted) { if (user.isDeleted) {
this.logger.info('skip: already deleted'); return 'skip: already deleted';
} }
const job = await this.queueService.createDeleteAccountJob(actor); const job = await this.queueService.createDeleteAccountJob(actor);

View File

@ -1,8 +1,8 @@
export type obj = { [x: string]: any }; export type Obj = { [x: string]: any };
export type ApObject = IObject | string | (IObject | string)[]; export type ApObject = IObject | string | (IObject | string)[];
export interface IObject { export interface IObject {
'@context'?: string | string[] | obj | obj[]; '@context'?: string | string[] | Obj | Obj[];
type: string | string[]; type: string | string[];
id?: string; id?: string;
name?: string | null; name?: string | null;

View File

@ -25,14 +25,7 @@ export class RoleEntityService {
public async pack( public async pack(
src: Role['id'] | Role, src: Role['id'] | Role,
me?: { id: User['id'] } | null | undefined, me?: { id: User['id'] } | null | undefined,
options?: {
detail?: boolean;
},
) { ) {
const opts = Object.assign({
detail: true,
}, options);
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
const assigns = await this.roleAssignmentsRepository.findBy({ const assigns = await this.roleAssignmentsRepository.findBy({
@ -65,9 +58,6 @@ export class RoleEntityService {
canEditMembersByModerator: role.canEditMembersByModerator, canEditMembersByModerator: role.canEditMembersByModerator,
policies: policies, policies: policies,
usersCount: assigns.length, usersCount: assigns.length,
...(opts.detail ? {
users: this.userEntityService.packMany(assigns.map(x => x.userId), me),
} : {}),
}); });
} }
@ -75,11 +65,8 @@ export class RoleEntityService {
public packMany( public packMany(
roles: any[], roles: any[],
me: { id: User['id'] }, me: { id: User['id'] },
options?: {
detail?: boolean;
},
) { ) {
return Promise.all(roles.map(x => this.pack(x, me, options))); return Promise.all(roles.map(x => this.pack(x, me)));
} }
} }

View File

@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js';
import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements from './endpoints/announcements.js';
import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@ -170,6 +171,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_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_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_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_removeKey from './endpoints/i/2fa/remove-key.js';
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
import * as ep___i_apps from './endpoints/i/apps.js'; import * as ep___i_apps from './endpoints/i/apps.js';
@ -276,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
import * as ep___ping from './endpoints/ping.js'; import * as ep___ping from './endpoints/ping.js';
import * as ep___pinnedUsers from './endpoints/pinned-users.js'; import * as ep___pinnedUsers from './endpoints/pinned-users.js';
import * as ep___promo_read from './endpoints/promo/read.js'; import * as ep___promo_read from './endpoints/promo/read.js';
import * as ep___roles_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.js';
import * as ep___roles_users from './endpoints/roles/users.js';
import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.js'; import * as ep___resetPassword from './endpoints/reset-password.js';
@ -382,6 +387,7 @@ const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useCla
const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default }; const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default };
const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default }; const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default };
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default }; const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default }; const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
@ -486,6 +492,7 @@ const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___
const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default }; const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default };
const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default }; const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default };
const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default }; const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default };
const $i_2fa_updateKey: Provider = { provide: 'ep:i/2fa/update-key', useClass: ep___i_2fa_updateKey.default };
const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default }; const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default };
const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default }; const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default };
const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default }; const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default };
@ -592,6 +599,9 @@ const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___
const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default }; const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default };
const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default }; const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default };
const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default }; const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default };
const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default };
const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default };
const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default };
const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default };
@ -702,6 +712,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_roles_assign, $admin_roles_assign,
$admin_roles_unassign, $admin_roles_unassign,
$admin_roles_updateDefaultPolicies, $admin_roles_updateDefaultPolicies,
$admin_roles_users,
$announcements, $announcements,
$antennas_create, $antennas_create,
$antennas_delete, $antennas_delete,
@ -806,6 +817,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_2fa_passwordLess, $i_2fa_passwordLess,
$i_2fa_registerKey, $i_2fa_registerKey,
$i_2fa_register, $i_2fa_register,
$i_2fa_updateKey,
$i_2fa_removeKey, $i_2fa_removeKey,
$i_2fa_unregister, $i_2fa_unregister,
$i_apps, $i_apps,
@ -912,6 +924,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$ping, $ping,
$pinnedUsers, $pinnedUsers,
$promo_read, $promo_read,
$roles_list,
$roles_show,
$roles_users,
$requestResetPassword, $requestResetPassword,
$resetDb, $resetDb,
$resetPassword, $resetPassword,
@ -1016,6 +1031,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_roles_assign, $admin_roles_assign,
$admin_roles_unassign, $admin_roles_unassign,
$admin_roles_updateDefaultPolicies, $admin_roles_updateDefaultPolicies,
$admin_roles_users,
$announcements, $announcements,
$antennas_create, $antennas_create,
$antennas_delete, $antennas_delete,
@ -1120,6 +1136,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_2fa_passwordLess, $i_2fa_passwordLess,
$i_2fa_registerKey, $i_2fa_registerKey,
$i_2fa_register, $i_2fa_register,
$i_2fa_updateKey,
$i_2fa_removeKey, $i_2fa_removeKey,
$i_2fa_unregister, $i_2fa_unregister,
$i_apps, $i_apps,
@ -1226,6 +1243,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$ping, $ping,
$pinnedUsers, $pinnedUsers,
$promo_read, $promo_read,
$roles_list,
$roles_show,
$roles_users,
$requestResetPassword, $requestResetPassword,
$resetDb, $resetDb,
$resetPassword, $resetPassword,

View File

@ -1,7 +1,7 @@
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy'; import * as OTPAuth from 'otpauth';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js'; import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
@ -155,19 +155,19 @@ export class SigninApiService {
}); });
} }
const verified = (speakeasy as any).totp.verify({ const delta = OTPAuth.TOTP.validate({
secret: profile.twoFactorSecret, secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
encoding: 'base32', digits: 6,
token: token, token,
window: 2, window: 1,
}); });
if (verified) { if (delta === null) {
return this.signinService.signin(request, reply, user);
} else {
return await fail(403, { return await fail(403, {
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
}); });
} else {
return this.signinService.signin(request, reply, user);
} }
} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) { } else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
if (!same && !profile.usePasswordLessLogin) { if (!same && !profile.usePasswordLessLogin) {

View File

@ -20,14 +20,14 @@ type File = {
}; };
// TODO: paramsの型をT['params']のスキーマ定義から推論する // TODO: paramsの型をT['params']のスキーマ定義から推論する
type executor<T extends IEndpointMeta, Ps extends Schema> = type Executor<T extends IEndpointMeta, Ps extends Schema> =
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) => (params: SchemaType<Ps>, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> { export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
public exec: (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>; public exec: (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
constructor(meta: T, paramDef: Ps, cb: executor<T, Ps>) { constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) {
const validate = ajv.compile(paramDef); const validate = ajv.compile(paramDef);
this.exec = (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => { this.exec = (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => {

View File

@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js';
import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements from './endpoints/announcements.js';
import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@ -170,6 +171,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_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_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_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_removeKey from './endpoints/i/2fa/remove-key.js';
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
import * as ep___i_apps from './endpoints/i/apps.js'; import * as ep___i_apps from './endpoints/i/apps.js';
@ -276,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
import * as ep___ping from './endpoints/ping.js'; import * as ep___ping from './endpoints/ping.js';
import * as ep___pinnedUsers from './endpoints/pinned-users.js'; import * as ep___pinnedUsers from './endpoints/pinned-users.js';
import * as ep___promo_read from './endpoints/promo/read.js'; import * as ep___promo_read from './endpoints/promo/read.js';
import * as ep___roles_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.js';
import * as ep___roles_users from './endpoints/roles/users.js';
import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.js'; import * as ep___resetPassword from './endpoints/reset-password.js';
@ -380,6 +385,7 @@ const eps = [
['admin/roles/assign', ep___admin_roles_assign], ['admin/roles/assign', ep___admin_roles_assign],
['admin/roles/unassign', ep___admin_roles_unassign], ['admin/roles/unassign', ep___admin_roles_unassign],
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies], ['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
['admin/roles/users', ep___admin_roles_users],
['announcements', ep___announcements], ['announcements', ep___announcements],
['antennas/create', ep___antennas_create], ['antennas/create', ep___antennas_create],
['antennas/delete', ep___antennas_delete], ['antennas/delete', ep___antennas_delete],
@ -484,6 +490,7 @@ const eps = [
['i/2fa/password-less', ep___i_2fa_passwordLess], ['i/2fa/password-less', ep___i_2fa_passwordLess],
['i/2fa/register-key', ep___i_2fa_registerKey], ['i/2fa/register-key', ep___i_2fa_registerKey],
['i/2fa/register', ep___i_2fa_register], ['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/remove-key', ep___i_2fa_removeKey],
['i/2fa/unregister', ep___i_2fa_unregister], ['i/2fa/unregister', ep___i_2fa_unregister],
['i/apps', ep___i_apps], ['i/apps', ep___i_apps],
@ -590,6 +597,9 @@ const eps = [
['ping', ep___ping], ['ping', ep___ping],
['pinned-users', ep___pinnedUsers], ['pinned-users', ep___pinnedUsers],
['promo/read', ep___promo_read], ['promo/read', ep___promo_read],
['roles/list', ep___roles_list],
['roles/show', ep___roles_show],
['roles/users', ep___roles_users],
['request-reset-password', ep___requestResetPassword], ['request-reset-password', ep___requestResetPassword],
['reset-db', ep___resetDb], ['reset-db', ep___resetDb],
['reset-password', ep___resetPassword], ['reset-password', ep___resetPassword],

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { DriveFilesRepository } from '@/models/index.js'; import type { DriveFilesRepository, UsersRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
@ -161,6 +161,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService, private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
@ -178,7 +181,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchFile); throw new ApiError(meta.errors.noSuchFile);
} }
const isModerator = await this.roleService.isModerator(me); const owner = file.userId ? await this.usersRepository.findOneByOrFail({
id: file.userId,
}) : null;
const iAmModerator = await this.roleService.isModerator(me);
const ownerIsModerator = owner ? await this.roleService.isModerator(owner) : false;
return { return {
id: file.id, id: file.id,
@ -207,8 +215,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
name: file.name, name: file.name,
md5: file.md5, md5: file.md5,
createdAt: file.createdAt.toISOString(), createdAt: file.createdAt.toISOString(),
requestIp: isModerator ? file.requestIp : null, requestIp: iAmModerator ? file.requestIp : null,
requestHeaders: isModerator ? file.requestHeaders : null, requestHeaders: iAmModerator && !ownerIsModerator ? file.requestHeaders : null,
}; };
}); });
} }

View File

@ -32,7 +32,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const roles = await this.rolesRepository.find({ const roles = await this.rolesRepository.find({
order: { lastUsedAt: 'DESC' }, order: { lastUsedAt: 'DESC' },
}); });
return await this.roleEntityService.packMany(roles, me, { detail: false }); return await this.roleEntityService.packMany(roles, me);
}); });
} }
} }

View File

@ -39,12 +39,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private roleEntityService: RoleEntityService, private roleEntityService: RoleEntityService,
) { ) {
super(meta, paramDef, async (ps) => { super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
if (role == null) { if (role == null) {
throw new ApiError(meta.errors.noSuchRole); throw new ApiError(meta.errors.noSuchRole);
} }
return await this.roleEntityService.pack(role); return await this.roleEntityService.pack(role, me);
}); });
} }
} }

View File

@ -0,0 +1,71 @@
import { Inject, Injectable } from '@nestjs/common';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin', 'role', 'users'],
requireCredential: false,
requireAdmin: true,
errors: {
noSuchRole: {
message: 'No such role.',
code: 'NO_SUCH_ROLE',
id: '224eff5e-2488-4b18-b3e7-f50d94421648',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roleId: { type: 'string', format: 'misskey:id' },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: ['roleId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
@Inject(DI.roleAssignmentsRepository)
private roleAssignmentsRepository: RoleAssignmentsRepository,
private queryService: QueryService,
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({
id: ps.roleId,
});
if (role == null) {
throw new ApiError(meta.errors.noSuchRole);
}
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
.innerJoinAndSelect('assign.user', 'user');
const assigns = await query
.take(ps.limit)
.getMany();
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
})));
});
}
}

View File

@ -59,12 +59,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new Error('cannot show info of admin'); throw new Error('cannot show info of admin');
} }
if (!await this.roleService.isAdministrator(_me)) {
return {
isSuspended: user.isSuspended,
};
}
const signins = await this.signinsRepository.findBy({ userId: user.id }); const signins = await this.signinsRepository.findBy({ userId: user.id });
const roles = await this.roleService.getUserRoles(user.id); const roles = await this.roleService.getUserRoles(user.id);
@ -89,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
moderationNote: profile.moderationNote, moderationNote: profile.moderationNote,
signins, signins,
policies: await this.roleService.getUserPolicies(user.id), policies: await this.roleService.getUserPolicies(user.id),
roles: await this.roleEntityService.packMany(roles, me, { detail: false }), roles: await this.roleEntityService.packMany(roles, me),
}; };
}); });
} }

View File

@ -1,7 +1,10 @@
import * as speakeasy from 'speakeasy'; import * as OTPAuth from 'otpauth';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UserProfilesRepository } from '@/models/index.js'; import type { UserProfilesRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
export const meta = { export const meta = {
@ -22,8 +25,14 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const token = ps.token.replace(/\s/g, ''); const token = ps.token.replace(/\s/g, '');
@ -34,13 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new Error('二段階認証の設定が開始されていません'); throw new Error('二段階認証の設定が開始されていません');
} }
const verified = (speakeasy as any).totp.verify({ const delta = OTPAuth.TOTP.validate({
secret: profile.twoFactorTempSecret, secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
encoding: 'base32', digits: 6,
token: token, token,
window: 1,
}); });
if (!verified) { if (delta === null) {
throw new Error('not verified'); throw new Error('not verified');
} }
@ -48,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
twoFactorSecret: profile.twoFactorTempSecret, twoFactorSecret: profile.twoFactorTempSecret,
twoFactorEnabled: true, twoFactorEnabled: true,
}); });
// Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true,
includeSecrets: true,
}));
}); });
} }
} }

View File

@ -25,7 +25,7 @@ export const paramDef = {
attestationObject: { type: 'string' }, attestationObject: { type: 'string' },
password: { type: 'string' }, password: { type: 'string' },
challengeId: { type: 'string' }, challengeId: { type: 'string' },
name: { type: 'string' }, name: { type: 'string', minLength: 1, maxLength: 30 },
}, },
required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'], required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
} as const; } as const;

View File

@ -1,12 +1,23 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserProfilesRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: {
noKey: {
message: 'No security key.',
code: 'NO_SECURITY_KEY',
id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -23,11 +34,45 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (ps.value === true) {
// セキュリティキーがなければパスワードレスを有効にはできない
const keyCount = await this.userSecurityKeysRepository.count({
where: {
userId: me.id,
},
select: {
id: true,
name: true,
lastUsed: true,
},
});
if (keyCount === 0) {
await this.userProfilesRepository.update(me.id, {
usePasswordLessLogin: false,
});
throw new ApiError(meta.errors.noKey);
}
}
await this.userProfilesRepository.update(me.id, { await this.userProfilesRepository.update(me.id, {
usePasswordLessLogin: ps.value, usePasswordLessLogin: ps.value,
}); });
// Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true,
includeSecrets: true,
}));
}); });
} }
} }

View File

@ -1,5 +1,5 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy'; import * as OTPAuth from 'otpauth';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository } from '@/models/index.js'; import type { UserProfilesRepository } from '@/models/index.js';
@ -42,25 +42,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} }
// Generate user's secret key // Generate user's secret key
const secret = speakeasy.generateSecret({ const secret = new OTPAuth.Secret();
length: 32,
});
await this.userProfilesRepository.update(me.id, { await this.userProfilesRepository.update(me.id, {
twoFactorTempSecret: secret.base32, twoFactorTempSecret: secret.base32,
}); });
// Get the data URL of the authenticator URL // Get the data URL of the authenticator URL
const url = speakeasy.otpauthURL({ const totp = new OTPAuth.TOTP({
secret: secret.base32, secret,
encoding: 'base32', digits: 6,
label: me.username, label: me.username,
issuer: this.config.host, issuer: this.config.host,
}); });
const dataUrl = await QRCode.toDataURL(url); const url = totp.toString();
const qr = await QRCode.toDataURL(url);
return { return {
qr: dataUrl, qr,
url, url,
secret: secret.base32, secret: secret.base32,
label: me.username, label: me.username,

View File

@ -50,6 +50,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
id: ps.credentialId, id: ps.credentialId,
}); });
// 使われているキーがなくなったらパスワードレスログインをやめる
const keyCount = await this.userSecurityKeysRepository.count({
where: {
userId: me.id,
},
select: {
id: true,
name: true,
lastUsed: true,
},
});
if (keyCount === 0) {
await this.userProfilesRepository.update(me.id, {
usePasswordLessLogin: false,
});
}
// Publish meUpdated event // Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true, detail: true,

View File

@ -1,7 +1,9 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UserProfilesRepository } from '@/models/index.js'; import type { UserProfilesRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
export const meta = { export const meta = {
@ -24,6 +26,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
@ -38,7 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
await this.userProfilesRepository.update(me.id, { await this.userProfilesRepository.update(me.id, {
twoFactorSecret: null, twoFactorSecret: null,
twoFactorEnabled: false, twoFactorEnabled: false,
usePasswordLessLogin: false,
}); });
// Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true,
includeSecrets: true,
}));
}); });
} }
} }

View File

@ -0,0 +1,78 @@
import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.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;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
const key = await this.userSecurityKeysRepository.findOneBy({
id: ps.credentialId,
});
if (key == null) {
throw new ApiError(meta.errors.noSuchKey);
}
if (key.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.userSecurityKeysRepository.update(key.id, {
name: ps.name,
});
// Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true,
includeSecrets: true,
}));
return {};
});
}
}

View File

@ -0,0 +1,37 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RolesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
export const meta = {
tags: ['role'],
requireCredential: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
},
required: [
],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
private roleEntityService: RoleEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const roles = await this.rolesRepository.findBy({
isPublic: true,
});
return await this.roleEntityService.packMany(roles, me);
});
}
}

View File

@ -0,0 +1,52 @@
import { Inject, Injectable } from '@nestjs/common';
import type { RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['role', 'users'],
requireCredential: false,
errors: {
noSuchRole: {
message: 'No such role.',
code: 'NO_SUCH_ROLE',
id: 'de5502bf-009a-4639-86c1-fec349e46dcb',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roleId: { type: 'string', format: 'misskey:id' },
},
required: ['roleId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
private roleEntityService: RoleEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({
id: ps.roleId,
isPublic: true,
});
if (role == null) {
throw new ApiError(meta.errors.noSuchRole);
}
return await this.roleEntityService.pack(role, me);
});
}
}

View File

@ -0,0 +1,71 @@
import { Inject, Injectable } from '@nestjs/common';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['role', 'users'],
requireCredential: false,
errors: {
noSuchRole: {
message: 'No such role.',
code: 'NO_SUCH_ROLE',
id: '30aaaee3-4792-48dc-ab0d-cf501a575ac5',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roleId: { type: 'string', format: 'misskey:id' },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: ['roleId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
@Inject(DI.roleAssignmentsRepository)
private roleAssignmentsRepository: RoleAssignmentsRepository,
private queryService: QueryService,
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({
id: ps.roleId,
isPublic: true,
});
if (role == null) {
throw new ApiError(meta.errors.noSuchRole);
}
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
.innerJoinAndSelect('assign.user', 'user');
const assigns = await query
.take(ps.limit)
.getMany();
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
})));
});
}
}

View File

@ -1,6 +1,7 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
@ -36,13 +37,13 @@ export const paramDef = {
properties: { properties: {
username: { type: 'string', nullable: true }, username: { type: 'string', nullable: true },
}, },
required: ['username'] required: ['username'],
}, },
{ {
properties: { properties: {
host: { type: 'string', nullable: true }, host: { type: 'string', nullable: true },
}, },
required: ['host'] required: ['host'],
}, },
], ],
} as const; } as const;
@ -53,6 +54,9 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -62,24 +66,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => {
if (ps.host) {
const q = this.usersRepository.createQueryBuilder('user')
.where('user.isSuspended = FALSE')
.andWhere('user.host LIKE :host', { host: sqlLikeEscape(ps.host.toLowerCase()) + '%' });
if (ps.username) { if (ps.username) {
q.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' });
} }
q.andWhere('user.updatedAt IS NOT NULL'); if (ps.host) {
q.orderBy('user.updatedAt', 'DESC'); if (ps.host === this.config.hostname || ps.host === '.') {
query.andWhere('user.host IS NULL');
} else {
query.andWhere('user.host LIKE :host', {
host: sqlLikeEscape(ps.host.toLowerCase()) + '%',
});
}
}
const users = await q.take(ps.limit).getMany(); return query;
};
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
return await this.userEntityService.packMany(users, me, { detail: ps.detail });
} else if (ps.username) {
let users: User[] = []; let users: User[] = [];
if (me) { if (me) {
@ -87,11 +93,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.select('following.followeeId') .select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id }); .where('following.followerId = :followerId', { followerId: me.id });
const query = this.usersRepository.createQueryBuilder('user') const query = setUsernameAndHostQuery()
.where(`user.id IN (${ followingQuery.getQuery() })`) .andWhere(`user.id IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id }) .andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE') .andWhere('user.isSuspended = FALSE')
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => { qb
.where('user.updatedAt IS NULL') .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
@ -105,11 +110,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.getMany(); .getMany();
if (users.length < ps.limit) { if (users.length < ps.limit) {
const otherQuery = await this.usersRepository.createQueryBuilder('user') const otherQuery = setUsernameAndHostQuery()
.where(`user.id NOT IN (${ followingQuery.getQuery() })`) .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE') .andWhere('user.isSuspended = FALSE')
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
.andWhere('user.updatedAt IS NOT NULL'); .andWhere('user.updatedAt IS NOT NULL');
otherQuery.setParameters(followingQuery.getParameters()); otherQuery.setParameters(followingQuery.getParameters());
@ -122,19 +125,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
users = users.concat(otherUsers); users = users.concat(otherUsers);
} }
} else { } else {
users = await this.usersRepository.createQueryBuilder('user') const query = setUsernameAndHostQuery()
.where('user.isSuspended = FALSE') .andWhere('user.isSuspended = FALSE')
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }) .andWhere('user.updatedAt IS NOT NULL');
.andWhere('user.updatedAt IS NOT NULL')
users = await query
.orderBy('user.updatedAt', 'DESC') .orderBy('user.updatedAt', 'DESC')
.take(ps.limit - users.length) .take(ps.limit - users.length)
.getMany(); .getMany();
} }
return await this.userEntityService.packMany(users, me, { detail: !!ps.detail }); return await this.userEntityService.packMany(users, me, { detail: !!ps.detail });
}
return [];
}); });
} }
} }

View File

@ -1,89 +0,0 @@
import * as assert from 'assert';
import * as mfm from 'mfm-js';
import { toHtml } from '../../src/mfm/to-html.js';
import { fromHtml } from '../../src/mfm/from-html.js';
describe('toHtml', () => {
test('br', () => {
const input = 'foo\nbar\nbaz';
const output = '<p><span>foo<br>bar<br>baz</span></p>';
assert.equal(toHtml(mfm.parse(input)), output);
});
test('br alt', () => {
const input = 'foo\r\nbar\rbaz';
const output = '<p><span>foo<br>bar<br>baz</span></p>';
assert.equal(toHtml(mfm.parse(input)), output);
});
});
describe('fromHtml', () => {
test('p', () => {
assert.deepStrictEqual(fromHtml('<p>a</p><p>b</p>'), 'a\n\nb');
});
test('block element', () => {
assert.deepStrictEqual(fromHtml('<div>a</div><div>b</div>'), 'a\nb');
});
test('inline element', () => {
assert.deepStrictEqual(fromHtml('<ul><li>a</li><li>b</li></ul>'), 'a\nb');
});
test('block code', () => {
assert.deepStrictEqual(fromHtml('<pre><code>a\nb</code></pre>'), '```\na\nb\n```');
});
test('inline code', () => {
assert.deepStrictEqual(fromHtml('<code>a</code>'), '`a`');
});
test('quote', () => {
assert.deepStrictEqual(fromHtml('<blockquote>a\nb</blockquote>'), '> a\n> b');
});
test('br', () => {
assert.deepStrictEqual(fromHtml('<p>abc<br><br/>d</p>'), 'abc\n\nd');
});
test('link with different text', () => {
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/b">c</a> d</p>'), 'a [c](https://example.com/b) d');
});
test('link with different text, but not encoded', () => {
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/ä">c</a> d</p>'), 'a [c](<https://example.com/ä>) d');
});
test('link with same text', () => {
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/b">https://example.com/b</a> d</p>'), 'a https://example.com/b d');
});
test('link with same text, but not encoded', () => {
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/ä">https://example.com/ä</a> d</p>'), 'a <https://example.com/ä> d');
});
test('link with no url', () => {
assert.deepStrictEqual(fromHtml('<p>a <a href="b">c</a> d</p>'), 'a [c](b) d');
});
test('link without href', () => {
assert.deepStrictEqual(fromHtml('<p>a <a>c</a> d</p>'), 'a c d');
});
test('link without text', () => {
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/b"></a> d</p>'), 'a https://example.com/b d');
});
test('link without both', () => {
assert.deepStrictEqual(fromHtml('<p>a <a></a> d</p>'), 'a d');
});
test('mention', () => {
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/@user" class="u-url mention">@user</a> d</p>'), 'a @user@example.com d');
});
test('hashtag', () => {
assert.deepStrictEqual(fromHtml('<p>a <a href="https://example.com/tags/a">#a</a> d</p>', ['#a']), 'a #a d');
});
});

View File

@ -1,83 +0,0 @@
/*
import * as assert from 'assert';
import { toDbReaction } from '../src/misc/reaction-lib.js';
describe('toDbReaction', async () => {
test('既存の文字列リアクションはそのまま', async () => {
assert.strictEqual(await toDbReaction('like'), 'like');
});
test('Unicodeプリンは寿司化不能とするため文字列化しない', async () => {
assert.strictEqual(await toDbReaction('🍮'), '🍮');
});
test('プリン以外の既存のリアクションは文字列化する like', async () => {
assert.strictEqual(await toDbReaction('👍'), 'like');
});
test('プリン以外の既存のリアクションは文字列化する love', async () => {
assert.strictEqual(await toDbReaction('❤️'), 'love');
});
test('プリン以外の既存のリアクションは文字列化する love 異体字セレクタなし', async () => {
assert.strictEqual(await toDbReaction('❤'), 'love');
});
test('プリン以外の既存のリアクションは文字列化する laugh', async () => {
assert.strictEqual(await toDbReaction('😆'), 'laugh');
});
test('プリン以外の既存のリアクションは文字列化する hmm', async () => {
assert.strictEqual(await toDbReaction('🤔'), 'hmm');
});
test('プリン以外の既存のリアクションは文字列化する surprise', async () => {
assert.strictEqual(await toDbReaction('😮'), 'surprise');
});
test('プリン以外の既存のリアクションは文字列化する congrats', async () => {
assert.strictEqual(await toDbReaction('🎉'), 'congrats');
});
test('プリン以外の既存のリアクションは文字列化する angry', async () => {
assert.strictEqual(await toDbReaction('💢'), 'angry');
});
test('プリン以外の既存のリアクションは文字列化する confused', async () => {
assert.strictEqual(await toDbReaction('😥'), 'confused');
});
test('プリン以外の既存のリアクションは文字列化する rip', async () => {
assert.strictEqual(await toDbReaction('😇'), 'rip');
});
test('それ以外はUnicodeのまま', async () => {
assert.strictEqual(await toDbReaction('🍅'), '🍅');
});
test('異体字セレクタ除去', async () => {
assert.strictEqual(await toDbReaction('㊗️'), '㊗');
});
test('異体字セレクタ除去 必要なし', async () => {
assert.strictEqual(await toDbReaction('㊗'), '㊗');
});
test('fallback - undefined', async () => {
assert.strictEqual(await toDbReaction(undefined), 'like');
});
test('fallback - null', async () => {
assert.strictEqual(await toDbReaction(null), 'like');
});
test('fallback - empty', async () => {
assert.strictEqual(await toDbReaction(''), 'like');
});
test('fallback - unknown', async () => {
assert.strictEqual(await toDbReaction('unknown'), 'like');
});
});
*/

View File

@ -0,0 +1,102 @@
import * as assert from 'assert';
import * as mfm from 'mfm-js';
import { Test } from '@nestjs/testing';
import { CoreModule } from '@/core/CoreModule.js';
import { MfmService } from '@/core/MfmService.js';
import { GlobalModule } from '@/GlobalModule.js';
describe('MfmService', () => {
let mfmService: MfmService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule],
}).compile();
mfmService = app.get<MfmService>(MfmService);
});
describe('toHtml', () => {
test('br', () => {
const input = 'foo\nbar\nbaz';
const output = '<p><span>foo<br>bar<br>baz</span></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
test('br alt', () => {
const input = 'foo\r\nbar\rbaz';
const output = '<p><span>foo<br>bar<br>baz</span></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
});
describe('fromHtml', () => {
test('p', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a</p><p>b</p>'), 'a\n\nb');
});
test('block element', () => {
assert.deepStrictEqual(mfmService.fromHtml('<div>a</div><div>b</div>'), 'a\nb');
});
test('inline element', () => {
assert.deepStrictEqual(mfmService.fromHtml('<ul><li>a</li><li>b</li></ul>'), 'a\nb');
});
test('block code', () => {
assert.deepStrictEqual(mfmService.fromHtml('<pre><code>a\nb</code></pre>'), '```\na\nb\n```');
});
test('inline code', () => {
assert.deepStrictEqual(mfmService.fromHtml('<code>a</code>'), '`a`');
});
test('quote', () => {
assert.deepStrictEqual(mfmService.fromHtml('<blockquote>a\nb</blockquote>'), '> a\n> b');
});
test('br', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>abc<br><br/>d</p>'), 'abc\n\nd');
});
test('link with different text', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/b">c</a> d</p>'), 'a [c](https://example.com/b) d');
});
test('link with different text, but not encoded', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/ä">c</a> d</p>'), 'a [c](<https://example.com/ä>) d');
});
test('link with same text', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/b">https://example.com/b</a> d</p>'), 'a https://example.com/b d');
});
test('link with same text, but not encoded', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/ä">https://example.com/ä</a> d</p>'), 'a <https://example.com/ä> d');
});
test('link with no url', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="b">c</a> d</p>'), 'a [c](b) d');
});
test('link without href', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a>c</a> d</p>'), 'a c d');
});
test('link without text', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/b"></a> d</p>'), 'a https://example.com/b d');
});
test('link without both', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a></a> d</p>'), 'a d');
});
test('mention', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/@user" class="u-url mention">@user</a> d</p>'), 'a @user@example.com d');
});
test('hashtag', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/tags/a">#a</a> d</p>', ['#a']), 'a #a d');
});
});
});

View File

@ -0,0 +1,92 @@
import * as assert from 'assert';
import { Test } from '@nestjs/testing';
import { CoreModule } from '@/core/CoreModule.js';
import { ReactionService } from '@/core/ReactionService.js';
import { GlobalModule } from '@/GlobalModule.js';
describe('ReactionService', () => {
let reactionService: ReactionService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule],
}).compile();
reactionService = app.get<ReactionService>(ReactionService);
});
describe('toDbReaction', () => {
test('絵文字リアクションはそのまま', async () => {
assert.strictEqual(await reactionService.toDbReaction('👍'), '👍');
assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅');
});
test('既存のリアクションは絵文字化する pudding', async () => {
assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮');
});
test('既存のリアクションは絵文字化する like', async () => {
assert.strictEqual(await reactionService.toDbReaction('like'), '👍');
});
test('既存のリアクションは絵文字化する love', async () => {
assert.strictEqual(await reactionService.toDbReaction('love'), '❤');
});
test('既存のリアクションは絵文字化する laugh', async () => {
assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆');
});
test('既存のリアクションは絵文字化する hmm', async () => {
assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔');
});
test('既存のリアクションは絵文字化する surprise', async () => {
assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮');
});
test('既存のリアクションは絵文字化する congrats', async () => {
assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉');
});
test('既存のリアクションは絵文字化する angry', async () => {
assert.strictEqual(await reactionService.toDbReaction('angry'), '💢');
});
test('既存のリアクションは絵文字化する confused', async () => {
assert.strictEqual(await reactionService.toDbReaction('confused'), '😥');
});
test('既存のリアクションは絵文字化する rip', async () => {
assert.strictEqual(await reactionService.toDbReaction('rip'), '😇');
});
test('既存のリアクションは絵文字化する star', async () => {
assert.strictEqual(await reactionService.toDbReaction('star'), '⭐');
});
test('異体字セレクタ除去', async () => {
assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗');
});
test('異体字セレクタ除去 必要なし', async () => {
assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗');
});
test('fallback - undefined', async () => {
assert.strictEqual(await reactionService.toDbReaction(undefined), '👍');
});
test('fallback - null', async () => {
assert.strictEqual(await reactionService.toDbReaction(null), '👍');
});
test('fallback - empty', async () => {
assert.strictEqual(await reactionService.toDbReaction(''), '👍');
});
test('fallback - unknown', async () => {
assert.strictEqual(await reactionService.toDbReaction('unknown'), '👍');
});
});
});

View File

@ -19,7 +19,7 @@ import Logger from '@/logger.js';
describe('Chart', () => { describe('Chart', () => {
const config = loadConfig(); const config = loadConfig();
const appLockService = { const appLockService = {
getChartInsertLock: jest.fn().mockImplementation(() => Promise.resolve(() => {})), getChartInsertLock: () => () => Promise.resolve(() => {}),
} as unknown as jest.Mocked<AppLockService>; } as unknown as jest.Mocked<AppLockService>;
let db: DataSource | undefined; let db: DataSource | undefined;

View File

@ -1,11 +1,11 @@
import * as assert from 'assert'; import * as assert from 'assert';
import { parse } from 'mfm-js'; import { parse } from 'mfm-js';
import { extractMentions } from '../../src/misc/extract-mentions.js'; import { extractMentions } from '@/misc/extract-mentions.js';
describe('Extract mentions', () => { describe('Extract mentions', () => {
test('simple', () => { test('simple', () => {
const ast = parse('@foo @bar @baz')!; const ast = parse('@foo @bar @baz');
const mentions = extractMentions(ast); const mentions = extractMentions(ast);
assert.deepStrictEqual(mentions, [{ assert.deepStrictEqual(mentions, [{
username: 'foo', username: 'foo',
@ -23,7 +23,7 @@ describe('Extract mentions', () => {
}); });
test('nested', () => { test('nested', () => {
const ast = parse('@foo **@bar** @baz')!; const ast = parse('@foo **@bar** @baz');
const mentions = extractMentions(ast); const mentions = extractMentions(ast);
assert.deepStrictEqual(mentions, [{ assert.deepStrictEqual(mentions, [{
username: 'foo', username: 'foo',

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
"noEmitOnError": false, "noEmitOnError": true,
"noImplicitAny": true, "noImplicitAny": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noUnusedParameters": false, "noUnusedParameters": false,

View File

@ -55,6 +55,7 @@ module.exports = {
'vue/multi-word-component-names': 'warn', 'vue/multi-word-component-names': 'warn',
'vue/require-v-for-key': 'warn', 'vue/require-v-for-key': 'warn',
'vue/no-unused-components': 'warn', 'vue/no-unused-components': 'warn',
'vue/no-unused-vars': 'warn',
'vue/valid-v-for': 'warn', 'vue/valid-v-for': 'warn',
'vue/return-in-computed-property': 'warn', 'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-destructure': 'warn', 'vue/no-setup-props-destructure': 'warn',

View File

@ -19,11 +19,11 @@
"@vue/compiler-sfc": "3.2.47", "@vue/compiler-sfc": "3.2.47",
"autobind-decorator": "2.4.0", "autobind-decorator": "2.4.0",
"autosize": "5.0.2", "autosize": "5.0.2",
"blurhash": "2.0.4", "blurhash": "2.0.5",
"broadcast-channel": "4.20.2", "broadcast-channel": "4.20.2",
"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3", "browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"canvas-confetti": "1.6.0", "canvas-confetti": "1.6.0",
"chart.js": "4.2.0", "chart.js": "4.2.1",
"chartjs-adapter-date-fns": "3.0.0", "chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1", "chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
@ -38,7 +38,7 @@
"insert-text-at-cursor": "0.3.0", "insert-text-at-cursor": "0.3.0",
"is-file-animated": "1.0.2", "is-file-animated": "1.0.2",
"json5": "2.2.3", "json5": "2.2.3",
"matter-js": "0.18.0", "matter-js": "0.19.0",
"mfm-js": "git+https://github.com/sim1222/mfm.js.git", "mfm-js": "git+https://github.com/sim1222/mfm.js.git",
"misetehoshii": "https://github.com/melt-adzuki/misetehoshii", "misetehoshii": "https://github.com/melt-adzuki/misetehoshii",
"misskey-js": "0.0.15", "misskey-js": "0.0.15",
@ -47,13 +47,12 @@
"punycode": "2.3.0", "punycode": "2.3.0",
"querystring": "0.2.1", "querystring": "0.2.1",
"rndstr": "1.0.0", "rndstr": "1.0.0",
"rollup": "3.14.0", "rollup": "3.17.2",
"s-age": "1.1.2", "s-age": "1.1.2",
"sanitize-html": "2.9.0", "sanitize-html": "2.10.0",
"sass": "1.58.0", "sass": "1.58.3",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"syuilo-password-strength": "0.0.1", "syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"three": "0.149.0", "three": "0.149.0",
@ -66,7 +65,7 @@
"uuid": "9.0.0", "uuid": "9.0.0",
"vanilla-tilt": "1.8.0", "vanilla-tilt": "1.8.0",
"vue-plyr": "7.0.0", "vue-plyr": "7.0.0",
"vite": "4.1.1", "vite": "4.1.2",
"vue": "3.2.47", "vue": "3.2.47",
"vue-prism-editor": "2.0.0-alpha.2", "vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next" "vuedraggable": "next"
@ -77,7 +76,7 @@
"@types/gulp": "4.0.10", "@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1", "@types/gulp-rename": "2.0.1",
"@types/matter-js": "0.18.2", "@types/matter-js": "0.18.2",
"@types/node": "18.13.0", "@types/node": "18.14.0",
"@types/punycode": "2.1.0", "@types/punycode": "2.1.0",
"@types/sanitize-html": "2.8.0", "@types/sanitize-html": "2.8.0",
"@types/seedrandom": "3.0.4", "@types/seedrandom": "3.0.4",
@ -86,16 +85,16 @@
"@types/uuid": "9.0.0", "@types/uuid": "9.0.0",
"@types/websocket": "1.0.5", "@types/websocket": "1.0.5",
"@types/ws": "8.5.4", "@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/eslint-plugin": "5.52.0",
"@typescript-eslint/parser": "5.51.0", "@typescript-eslint/parser": "5.52.0",
"@vue/runtime-core": "3.2.47", "@vue/runtime-core": "3.2.47",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "12.5.1", "cypress": "12.6.0",
"eslint": "8.33.0", "eslint": "8.34.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.9.0", "eslint-plugin-vue": "9.9.0",
"start-server-and-test": "1.15.3", "start-server-and-test": "1.15.4",
"vue-eslint-parser": "9.1.0", "vue-eslint-parser": "9.1.0",
"vue-tsc": "1.0.24" "vue-tsc": "1.1.4"
} }
} }

View File

@ -43,7 +43,7 @@ const emit = defineEmits<{
}>(); }>();
const uiWindow = shallowRef<InstanceType<typeof MkWindow>>(); const uiWindow = shallowRef<InstanceType<typeof MkWindow>>();
const comment = ref(props.initialComment || ''); const comment = ref(props.initialComment ?? '');
function send() { function send() {
os.apiWithDialog('users/report-abuse', { os.apiWithDialog('users/report-abuse', {

View File

@ -209,7 +209,7 @@ function exec() {
} }
} else if (props.type === 'hashtag') { } else if (props.type === 'hashtag') {
if (!props.q || props.q === '') { if (!props.q || props.q === '') {
hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') || '[]'); hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]');
fetching.value = false; fetching.value = false;
} else { } else {
const cacheKey = `autocomplete:hashtag:${props.q}`; const cacheKey = `autocomplete:hashtag:${props.q}`;

View File

@ -69,7 +69,7 @@ const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown
if (loaded) { if (loaded) {
available.value = true; available.value = true;
} else { } else {
(document.getElementById(scriptId.value) || document.head.appendChild(Object.assign(document.createElement('script'), { (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), {
async: true, async: true,
id: scriptId.value, id: scriptId.value,
src: src.value, src: src.value,

View File

@ -22,9 +22,6 @@ import * as game from '@/scripts/clicker-game';
import number from '@/filters/number'; import number from '@/filters/number';
import { claimAchievement } from '@/scripts/achievements'; import { claimAchievement } from '@/scripts/achievements';
defineProps<{
}>();
const saveData = game.saveData; const saveData = game.saveData;
const cookies = computed(() => saveData.value?.cookies); const cookies = computed(() => saveData.value?.cookies);
let cps = $ref(0); let cps = $ref(0);

View File

@ -7,7 +7,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { length } from 'stringz';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import { concat } from '@/scripts/array'; import { concat } from '@/scripts/array';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
@ -23,7 +22,7 @@ const emit = defineEmits<{
const label = computed(() => { const label = computed(() => {
return concat([ return concat([
props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [], props.note.text ? [i18n.t('_cw.chars', { count: props.note.text.length })] : [],
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [], props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [],
props.note.poll != null ? [i18n.ts.poll] : [], props.note.poll != null ? [i18n.ts.poll] : [],
] as string[][]).join(' / '); ] as string[][]).join(' / ');

View File

@ -14,8 +14,12 @@
</div> </div>
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header> <header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
<div v-if="text" :class="$style.text"><Mfm :text="text"/></div> <div v-if="text" :class="$style.text"><Mfm :text="text"/></div>
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown"> <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></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>
</MkInput> </MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus> <MkSelect v-if="select" v-model="selectedValue" autofocus>
<template v-if="select.items"> <template v-if="select.items">
@ -28,7 +32,7 @@
</template> </template>
</MkSelect> </MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> <MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div> </div>
<div v-if="actions" :class="$style.buttons"> <div v-if="actions" :class="$style.buttons">
@ -47,9 +51,12 @@ import MkSelect from '@/components/MkSelect.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
type Input = { type Input = {
type: HTMLInputElement['type']; type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
placeholder?: string | null; placeholder?: string | null;
default: any | null; autocomplete?: string;
default: string | number | null;
minLength?: number;
maxLength?: number;
}; };
type Select = { type Select = {
@ -98,8 +105,28 @@ const emit = defineEmits<{
const modal = shallowRef<InstanceType<typeof MkModal>>(); const modal = shallowRef<InstanceType<typeof MkModal>>();
const inputValue = ref(props.input?.default || null); const inputValue = ref<string | number | null>(props.input?.default ?? null);
const selectedValue = ref(props.select?.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;
});
function done(canceled: boolean, result?) { function done(canceled: boolean, result?) {
emit('done', { canceled, result }); emit('done', { canceled, result });

View File

@ -1,13 +1,20 @@
<template> <template>
<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]"> <div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]">
<div :class="$style.header" class="_button" @click="toggle"> <div :class="$style.header" class="_button" @click="toggle">
<span :class="$style.headerIcon"><slot name="icon"></slot></span> <div :class="$style.headerIcon"><slot name="icon"></slot></div>
<span :class="$style.headerText"><slot name="label"></slot></span> <div :class="$style.headerText">
<span :class="$style.headerRight"> <div :class="$style.headerTextMain">
<slot name="label"></slot>
</div>
<div :class="$style.headerTextSub">
<slot name="caption"></slot>
</div>
</div>
<div :class="$style.headerRight">
<span :class="$style.headerRightText"><slot name="suffix"></slot></span> <span :class="$style.headerRightText"><slot name="suffix"></slot></span>
<i v-if="opened" class="ti ti-chevron-up icon"></i> <i v-if="opened" class="ti ti-chevron-up icon"></i>
<i v-else class="ti ti-chevron-down icon"></i> <i v-else class="ti ti-chevron-down icon"></i>
</span> </div>
</div> </div>
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }"> <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
<Transition <Transition
@ -139,6 +146,17 @@ onMounted(() => {
} }
} }
.headerUpper {
display: flex;
align-items: center;
}
.headerLower {
color: var(--fgTransparentWeak);
font-size: .85em;
padding-left: 4px;
}
.headerIcon { .headerIcon {
margin-right: 0.75em; margin-right: 0.75em;
flex-shrink: 0; flex-shrink: 0;
@ -161,6 +179,15 @@ onMounted(() => {
padding-right: 12px; padding-right: 12px;
} }
.headerTextMain {
}
.headerTextSub {
color: var(--fgTransparentWeak);
font-size: .85em;
}
.headerRight { .headerRight {
margin-left: auto; margin-left: auto;
opacity: 0.7; opacity: 0.7;

View File

@ -23,7 +23,7 @@
@input="onInput" @input="onInput"
> >
<datalist v-if="datalist" :id="id"> <datalist v-if="datalist" :id="id">
<option v-for="data in datalist" :value="data"/> <option v-for="data in datalist" :key="data" :value="data"/>
</datalist> </datalist>
<div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div> <div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div>
</div> </div>
@ -41,7 +41,7 @@ import { useInterval } from '@/scripts/use-interval';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const props = defineProps<{ const props = defineProps<{
modelValue: string | number; modelValue: string | number | null;
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
required?: boolean; required?: boolean;
readonly?: boolean; readonly?: boolean;
@ -49,7 +49,7 @@ const props = defineProps<{
pattern?: string; pattern?: string;
placeholder?: string; placeholder?: string;
autofocus?: boolean; autofocus?: boolean;
autocomplete?: boolean; autocomplete?: string;
spellcheck?: boolean; spellcheck?: boolean;
step?: any; step?: any;
datalist?: string[]; datalist?: string[];

View File

@ -45,8 +45,8 @@ onMounted(() => {
src: media.url, src: media.url,
w: media.properties.width, w: media.properties.width,
h: media.properties.height, h: media.properties.height,
alt: media.comment || media.name, alt: media.comment ?? media.name,
comment: media.comment || media.name, comment: media.comment ?? media.name,
}; };
if (media.properties.orientation != null && media.properties.orientation >= 5) { if (media.properties.orientation != null && media.properties.orientation >= 5) {
[item.w, item.h] = [item.h, item.w]; [item.w, item.h] = [item.h, item.w];
@ -90,8 +90,8 @@ onMounted(() => {
[itemData.w, itemData.h] = [itemData.h, itemData.w]; [itemData.w, itemData.h] = [itemData.h, itemData.w];
} }
itemData.msrc = file.thumbnailUrl; itemData.msrc = file.thumbnailUrl;
itemData.alt = file.comment || file.name; itemData.alt = file.comment ?? file.name;
itemData.comment = file.comment || file.name; itemData.comment = file.comment ?? file.name;
itemData.thumbCropped = true; itemData.thumbCropped = true;
}); });

View File

@ -199,6 +199,8 @@ const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false); const showContent = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const isLong = (appearNote.cw == null && appearNote.text != null && ( const isLong = (appearNote.cw == null && appearNote.text != null && (
(appearNote.text.includes('$[x3')) ||
(appearNote.text.includes('$[x4')) ||
(appearNote.text.split('\n').length > 9) || (appearNote.text.split('\n').length > 9) ||
(appearNote.text.length > 500) || (appearNote.text.length > 500) ||
(appearNote.files.length >= 5) || (appearNote.files.length >= 5) ||

View File

@ -54,7 +54,7 @@ const props = withDefaults(defineProps<{
showGlobalToggle: true, showGlobalToggle: true,
}); });
let includingTypes = $computed(() => props.includingTypes || []); let includingTypes = $computed(() => props.includingTypes ?? []);
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();

View File

@ -18,7 +18,7 @@
</template> </template>
<div :class="$style.root" :style="{ background: pageMetadata?.value?.bg }" style="container-type: inline-size;"> <div :class="$style.root" :style="{ background: pageMetadata?.value?.bg }" style="container-type: inline-size;">
<RouterView :router="router"/> <RouterView :key="reloadCount" :router="router"/>
</div> </div>
</MkWindow> </MkWindow>
</template> </template>
@ -67,6 +67,10 @@ const buttonsLeft = $computed(() => {
}); });
const buttonsRight = $computed(() => { const buttonsRight = $computed(() => {
const buttons = [{ const buttons = [{
icon: 'ti ti-reload',
title: i18n.ts.reload,
onClick: reload,
}, {
icon: 'ti ti-player-eject', icon: 'ti ti-player-eject',
title: i18n.ts.showInPage, title: i18n.ts.showInPage,
onClick: expand, onClick: expand,
@ -74,6 +78,7 @@ const buttonsRight = $computed(() => {
return buttons; return buttons;
}); });
let reloadCount = $ref(0);
router.addListener('push', ctx => { router.addListener('push', ctx => {
history.push({ path: ctx.path, key: ctx.key }); history.push({ path: ctx.path, key: ctx.key });
@ -115,6 +120,10 @@ function back() {
router.replace(history[history.length - 1].path, history[history.length - 1].key); router.replace(history[history.length - 1].path, history[history.length - 1].key);
} }
function reload() {
reloadCount++;
}
function close() { function close() {
windowEl.close(); windowEl.close();
} }

View File

@ -104,7 +104,7 @@ const {
enableInfiniteScroll, enableInfiniteScroll,
} = defaultStore.reactiveState; } = defaultStore.reactiveState;
const contentEl = $computed(() => props.pagination.pageEl || rootEl); const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
const scrollableElement = $computed(() => getScrollContainer(contentEl)); const scrollableElement = $computed(() => getScrollContainer(contentEl));
// //

View File

@ -75,7 +75,6 @@ import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor'; import insertTextAtCursor from 'insert-text-at-cursor';
import { length } from 'stringz';
import { toASCII } from 'punycode/'; import { toASCII } from 'punycode/';
import * as Acct from 'misskey-js/built/acct'; import * as Acct from 'misskey-js/built/acct';
import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue';
@ -158,7 +157,7 @@ let autocomplete = $ref(null);
let draghover = $ref(false); let draghover = $ref(false);
let quoteId = $ref(null); let quoteId = $ref(null);
let hasNotSpecifiedMentions = $ref(false); let hasNotSpecifiedMentions = $ref(false);
let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') || '[]')); let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'));
let imeText = $ref(''); let imeText = $ref('');
const draftKey = $computed((): string => { const draftKey = $computed((): string => {
@ -204,7 +203,7 @@ const submitText = $computed((): string => {
}); });
const textLength = $computed((): number => { const textLength = $computed((): number => {
return length((text + imeText).trim()); return (text + imeText).trim().length;
}); });
const maxTextLength = $computed((): number => { const maxTextLength = $computed((): number => {
@ -543,7 +542,7 @@ function onDrop(ev): void {
} }
function saveDraft() { function saveDraft() {
const draftData = JSON.parse(miLocalStorage.getItem('drafts') || '{}'); const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
draftData[draftKey] = { draftData[draftKey] = {
updatedAt: new Date(), updatedAt: new Date(),
@ -652,7 +651,7 @@ async function post(ev?: MouseEvent) {
emit('posted'); emit('posted');
if (postData.text && postData.text !== '') { if (postData.text && postData.text !== '') {
const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
const history = JSON.parse(miLocalStorage.getItem('hashtags') || '[]') as string[]; const history = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]') as string[];
miLocalStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history)))); miLocalStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history))));
} }
posting = false; posting = false;
@ -756,7 +755,7 @@ onMounted(() => {
nextTick(() => { nextTick(() => {
// 稿 // 稿
if (!props.instant && !props.mention && !props.specified) { if (!props.instant && !props.mention && !props.specified) {
const draft = JSON.parse(miLocalStorage.getItem('drafts') || '{}')[draftKey]; const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey];
if (draft) { if (draft) {
text = draft.data.text; text = draft.data.text;
useCw = draft.data.useCw; useCw = draft.data.useCw;

View File

@ -1,10 +1,15 @@
<template> <template>
<MkA v-adaptive-bg :to="`/admin/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }"> <MkA v-adaptive-bg :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }">
<div :class="$style.title"> <div :class="$style.title">
<span :class="$style.icon"> <span :class="$style.icon">
<template v-if="role.iconUrl">
<img :class="$style.badge" :src="role.iconUrl"/>
</template>
<template v-else>
<i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i> <i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i>
<i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i> <i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i>
<i v-else class="ti ti-user" style="opacity: 0.7;"></i> <i v-else class="ti ti-user" style="opacity: 0.7;"></i>
</template>
</span> </span>
<span :class="$style.name">{{ role.name }}</span> <span :class="$style.name">{{ role.name }}</span>
<span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span> <span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span>
@ -20,6 +25,7 @@ import { i18n } from '@/i18n';
const props = defineProps<{ const props = defineProps<{
role: any; role: any;
forModeration: boolean;
}>(); }>();
</script> </script>
@ -38,6 +44,11 @@ const props = defineProps<{
margin-right: 8px; margin-right: 8px;
} }
.badge {
height: 1.3em;
vertical-align: -20%;
}
.name { .name {
font-weight: bold; font-weight: bold;
} }

View File

@ -34,7 +34,7 @@ import { useInterval } from '@/scripts/use-interval';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const props = defineProps<{ const props = defineProps<{
modelValue: string; modelValue: string | null;
required?: boolean; required?: boolean;
readonly?: boolean; readonly?: boolean;
disabled?: boolean; disabled?: boolean;
@ -48,7 +48,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'change', _ev: KeyboardEvent): void; (ev: 'change', _ev: KeyboardEvent): void;
(ev: 'update:modelValue', value: string): void; (ev: 'update:modelValue', value: string | null): void;
}>(); }>();
const slots = useSlots(); const slots = useSlots();

View File

@ -10,7 +10,7 @@
<template #prefix>@</template> <template #prefix>@</template>
<template #suffix>@{{ host }}</template> <template #suffix>@{{ host }}</template>
</MkInput> </MkInput>
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password> <MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :with-password-toggle="true" required data-cy-signin-password>
<template #prefix><i class="ti ti-lock"></i></template> <template #prefix><i class="ti ti-lock"></i></template>
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> <template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</MkInput> </MkInput>
@ -28,11 +28,11 @@
</div> </div>
<div class="twofa-group totp-group"> <div class="twofa-group totp-group">
<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p> <p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p>
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" :with-password-toggle="true" required> <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :with-password-toggle="true" required>
<template #label>{{ i18n.ts.password }}</template> <template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template> <template #prefix><i class="ti ti-lock"></i></template>
</MkInput> </MkInput>
<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false" required> <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="one-time-code" :spellcheck="false" required>
<template #label>{{ i18n.ts.token }}</template> <template #label>{{ i18n.ts.token }}</template>
<template #prefix><i class="ti ti-123"></i></template> <template #prefix><i class="ti ti-123"></i></template>
</MkInput> </MkInput>

View File

@ -1,10 +1,10 @@
<template> <template>
<XNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> <MkNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, provide, onUnmounted } from 'vue'; import { computed, provide, onUnmounted } from 'vue';
import XNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import { stream } from '@/stream'; import { stream } from '@/stream';
import * as sound from '@/scripts/sound'; import * as sound from '@/scripts/sound';
import { $i } from '@/account'; import { $i } from '@/account';
@ -24,7 +24,7 @@ const emit = defineEmits<{
provide('inChannel', computed(() => props.src === 'channel')); provide('inChannel', computed(() => props.src === 'channel'));
const tlComponent: InstanceType<typeof XNotes> = $ref(); const tlComponent: InstanceType<typeof MkNotes> = $ref();
const prepend = note => { const prepend = note => {
tlComponent.pagingComponent?.prepend(note); tlComponent.pagingComponent?.prepend(note);

View File

@ -7,9 +7,9 @@
</div> </div>
</template> </template>
<template #default="{ items: users }"> <template #default="{ items }">
<div class="efvhhmdq"> <div class="efvhhmdq">
<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/> <MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/>
</div> </div>
</template> </template>
</MkPagination> </MkPagination>
@ -20,10 +20,13 @@ import MkUserInfo from '@/components/MkUserInfo.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue'; import MkPagination, { Paging } from '@/components/MkPagination.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const props = defineProps<{ const props = withDefaults(defineProps<{
pagination: Paging; pagination: Paging;
noGap?: boolean; noGap?: boolean;
}>(); extractor?: (item: any) => any;
}>(), {
extractor: (item) => item,
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -16,7 +16,7 @@
<template #label>{{ i18n.ts.username }}</template> <template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template> <template #prefix>@</template>
</MkInput> </MkInput>
<MkInput v-model="host" @update:model-value="search"> <MkInput v-model="host" :datalist="[hostname]" @update:model-value="search">
<template #label>{{ i18n.ts.host }}</template> <template #label>{{ i18n.ts.host }}</template>
<template #prefix>@</template> <template #prefix>@</template>
</MkInput> </MkInput>
@ -61,6 +61,7 @@ import * as os from '@/os';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { $i } from '@/account'; import { $i } from '@/account';
import { hostname } from '@/config';
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'ok', selected: misskey.entities.UserDetailed): void; (ev: 'ok', selected: misskey.entities.UserDetailed): void;
@ -115,7 +116,7 @@ onMounted(() => {
os.api('users/show', { os.api('users/show', {
userIds: defaultStore.state.recentlyUsedUsers, userIds: defaultStore.state.recentlyUsedUsers,
}).then(users => { }).then(users => {
if (props.includeSelf) { if (props.includeSelf && users.find(x => $i ? x.id === $i.id : true) == null) {
recentUsers = [$i, ...users]; recentUsers = [$i, ...users];
} else { } else {
recentUsers = users; recentUsers = users;

View File

@ -24,7 +24,7 @@ const rawUrl = computed(() => {
return props.url; return props.url;
} }
if (props.host == null && !customEmojiName.value.includes('@')) { if (props.host == null && !customEmojiName.value.includes('@')) {
return customEmojis.value.find(x => x.name === customEmojiName.value)?.url || null; return customEmojis.value.find(x => x.name === customEmojiName.value)?.url ?? null;
} }
return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`; return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
}); });
@ -32,7 +32,7 @@ const rawUrl = computed(() => {
const url = computed(() => const url = computed(() =>
defaultStore.reactiveState.disableShowingAnimatedImages.value && rawUrl.value defaultStore.reactiveState.disableShowingAnimatedImages.value && rawUrl.value
? getStaticImageUrl(rawUrl.value) ? getStaticImageUrl(rawUrl.value)
: rawUrl.value : rawUrl.value,
); );
const alt = computed(() => `:${customEmojiName.value}:`); const alt = computed(() => `:${customEmojiName.value}:`);

View File

@ -1,23 +1,33 @@
<template> <template>
<div ref="el" :class="$style.tabs" @wheel="onTabWheel"> <div ref="el" :class="$style.tabs" @wheel="onTabWheel">
<div :class="$style.tabsInner"> <div :class="$style.tabsInner">
<button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" <button
v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]" class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]"
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"> @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"
>
<div :class="$style.tabInner"> <div :class="$style.tabInner">
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
<div v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)" <div
:class="$style.tabTitle">{{ t.title }}</div> v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)"
<Transition v-else mode="in-out" @enter="enter" @after-enter="afterEnter" @leave="leave" :class="$style.tabTitle"
@after-leave="afterLeave"> >
{{ t.title }}
</div>
<Transition
v-else mode="in-out" @enter="enter" @after-enter="afterEnter" @leave="leave"
@after-leave="afterLeave"
>
<div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div> <div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div>
</Transition> </Transition>
</div> </div>
</button> </button>
</div> </div>
<div ref="tabHighlightEl" <div
:class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"></div> ref="tabHighlightEl"
</div> :class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"
></div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -206,8 +216,8 @@ onUnmounted(() => {
align-items: center; align-items: center;
} }
.tabIcon+.tabTitle { .tabIcon + .tabTitle {
padding-left: 8px; padding-left: 4px;
} }
.tabTitle { .tabTitle {

View File

@ -2,9 +2,9 @@
<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }"> <div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }">
<div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> <div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu"> <div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
<MkAvatar :class="$style.avatar" :user="$i" /> <MkAvatar :class="$style.avatar" :user="$i"/>
</div> </div>
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttonsLeft" /> <div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttonsLeft"/>
<template v-if="metadata"> <template v-if="metadata">
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top"> <div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
@ -39,12 +39,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, ref, inject } from 'vue'; import { onMounted, onUnmounted, ref, inject } from 'vue';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import XTabs, { Tab } from './MkPageHeader.tabs.vue';
import { scrollToTop } from '@/scripts/scroll'; import { scrollToTop } from '@/scripts/scroll';
import { globalEvents } from '@/events'; import { globalEvents } from '@/events';
import { injectPageMetadata } from '@/scripts/page-metadata'; import { injectPageMetadata } from '@/scripts/page-metadata';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import XTabs, { Tab } from './MkPageHeader.tabs.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
tabs?: Tab[]; tabs?: Tab[];
@ -100,7 +100,7 @@ function onTabClick(): void {
} }
const calcBg = () => { const calcBg = () => {
const rawBg = metadata?.bg || 'var(--bg)'; const rawBg = metadata?.bg ?? 'var(--bg)';
const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
tinyBg.setAlpha(0.85); tinyBg.setAlpha(0.85);
bg.value = tinyBg.toRgbString(); bg.value = tinyBg.toRgbString();

View File

@ -286,7 +286,7 @@ export default defineComponent({
case 'hashtag': { case 'hashtag': {
return [h(MkA, { return [h(MkA, {
key: Math.random(), key: Math.random(),
to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`,
style: 'color:var(--hashtag);', style: 'color:var(--hashtag);',
}, `#${token.props.hashtag}`)]; }, `#${token.props.hashtag}`)];
} }

View File

@ -246,7 +246,10 @@ export function inputText(props: {
title?: string | null; title?: string | null;
text?: string | null; text?: string | null;
placeholder?: string | null; placeholder?: string | null;
autocomplete?: string;
default?: string | null; default?: string | null;
minLength?: number;
maxLength?: number;
}): Promise<{ canceled: true; result: undefined; } | { }): Promise<{ canceled: true; result: undefined; } | {
canceled: false; result: string; canceled: false; result: string;
}> { }> {
@ -257,7 +260,10 @@ export function inputText(props: {
input: { input: {
type: props.type, type: props.type,
placeholder: props.placeholder, placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default, default: props.default,
minLength: props.minLength,
maxLength: props.maxLength,
}, },
}, { }, {
done: result => { done: result => {
@ -271,6 +277,7 @@ export function inputNumber(props: {
title?: string | null; title?: string | null;
text?: string | null; text?: string | null;
placeholder?: string | null; placeholder?: string | null;
autocomplete?: string;
default?: number | null; default?: number | null;
}): Promise<{ canceled: true; result: undefined; } | { }): Promise<{ canceled: true; result: undefined; } | {
canceled: false; result: number; canceled: false; result: number;
@ -282,6 +289,7 @@ export function inputNumber(props: {
input: { input: {
type: 'number', type: 'number',
placeholder: props.placeholder, placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default, default: props.default,
}, },
}, { }, {

View File

@ -84,6 +84,10 @@
</div> </div>
<p>{{ i18n.ts._aboutMisskey.morePatrons }}</p> <p>{{ i18n.ts._aboutMisskey.morePatrons }}</p>
</FormSection> </FormSection>
<FormSection>
<template #label>Credits</template>
<p>Misskeyで使われる画像の一部は許可を得てあの子がこっちを見てるメーカーで作成したものが含まれます</p>
</FormSection>
</div> </div>
</MkSpacer> </MkSpacer>
</div> </div>
@ -114,6 +118,9 @@ const patronsWithIcon = [{
}, { }, {
name: 'narazaka', name: 'narazaka',
icon: 'https://misskey-hub.net/patrons/e3affff31ffb4877b1196c7360abc3e5.jpg', icon: 'https://misskey-hub.net/patrons/e3affff31ffb4877b1196c7360abc3e5.jpg',
}, {
name: 'ひとぅ',
icon: 'https://misskey-hub.net/patrons/8cc0d0a0a6d84c88bca1aedabf6ed5ab.jpg',
}]; }];
const patrons = [ const patrons = [

View File

@ -113,7 +113,7 @@ function onTabClick(tab: Tab, ev: MouseEvent): void {
} }
const calcBg = () => { const calcBg = () => {
const rawBg = metadata?.bg || 'var(--bg)'; const rawBg = metadata?.bg ?? 'var(--bg)';
const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
tinyBg.setAlpha(0.85); tinyBg.setAlpha(0.85);
bg.value = tinyBg.toRgbString(); bg.value = tinyBg.toRgbString();

View File

@ -44,6 +44,9 @@
<MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> <MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
</div> </div>
</div> </div>
<MkButton class="button" @click="more()">
<i class="ti ti-reload"></i>{{ i18n.ts.more }}
</MkButton>
</div> </div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -123,7 +126,21 @@ function save(ad) {
}); });
} }
} }
function more() {
os.api('admin/ad/list', { untilId: ads.reduce((acc, ad) => ad.id != null ? ad : acc).id }).then(adsResponse => {
ads = ads.concat(adsResponse.map(r => {
const exdate = new Date(r.expiresAt);
const stdate = new Date(r.startsAt);
exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff);
stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff);
return {
...r,
expiresAt: exdate.toISOString().slice(0, 16),
startsAt: stdate.toISOString().slice(0, 16),
};
}));
});
}
const headerActions = $computed(() => [{ const headerActions = $computed(() => [{
asFullButton: true, asFullButton: true,
icon: 'ti ti-plus', icon: 'ti ti-plus',

View File

@ -16,16 +16,29 @@
<MkFolder v-if="role.target === 'manual'" default-open> <MkFolder v-if="role.target === 'manual'" default-open>
<template #icon><i class="ti ti-users"></i></template> <template #icon><i class="ti ti-users"></i></template>
<template #label>{{ i18n.ts.users }}</template> <template #label>{{ i18n.ts.users }}</template>
<template #suffix>{{ role.users.length }}</template> <template #suffix>{{ role.usersCount }}</template>
<div class="_gaps"> <div class="_gaps">
<MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> <MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<div v-for="user in role.users" :key="user.id" :class="$style.userItem"> <MkPagination :pagination="usersPagination">
<MkA :class="$style.user" :to="`/user-info/${user.id}`"> <template #empty>
<MkUserCardMini :user="user"/> <div class="_fullinfo">
</MkA> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<button class="_button" :class="$style.unassign" @click="unassign(user, $event)"><i class="ti ti-x"></i></button> <div>{{ i18n.ts.noUsers }}</div>
</div> </div>
</template>
<template #default="{ items }">
<div class="_gaps_s">
<div v-for="item in items" :key="item.user.id" :class="$style.userItem">
<MkA :class="$style.user" :to="`/user-info/${item.user.id}`">
<MkUserCardMini :user="item.user"/>
</MkA>
<button class="_button" :class="$style.unassign" @click="unassign(item.user, $event)"><i class="ti ti-x"></i></button>
</div>
</div>
</template>
</MkPagination>
</div> </div>
</MkFolder> </MkFolder>
<MkInfo v-else>{{ i18n.ts._role.isConditionalRole }}</MkInfo> <MkInfo v-else>{{ i18n.ts._role.isConditionalRole }}</MkInfo>
@ -47,6 +60,7 @@ import { useRouter } from '@/router';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
const router = useRouter(); const router = useRouter();
@ -54,6 +68,14 @@ const props = defineProps<{
id?: string; id?: string;
}>(); }>();
const usersPagination = {
endpoint: 'admin/roles/users' as const,
limit: 20,
params: computed(() => ({
roleId: props.id,
})),
};
const role = reactive(await os.api('admin/roles/show', { const role = reactive(await os.api('admin/roles/show', {
roleId: props.id, roleId: props.id,
})); }));

View File

@ -133,7 +133,7 @@
</div> </div>
</MkFolder> </MkFolder>
<div class="_gaps_s"> <div class="_gaps_s">
<MkRolePreview v-for="role in roles" :key="role.id" :role="role"/> <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="true"/>
</div> </div>
</div> </div>
</MkSpacer> </MkSpacer>

View File

@ -4,7 +4,7 @@
<div ref="rootEl" v-hotkey.global="keymap" class="tqmomfks"> <div ref="rootEl" v-hotkey.global="keymap" class="tqmomfks">
<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
<div class="tl blur"> <div class="tl blur">
<XTimeline <MkTimeline
ref="tlEl" :key="antennaId" ref="tlEl" :key="antennaId"
class="tl" class="tl"
src="antenna" src="antenna"
@ -19,7 +19,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import XTimeline from '@/components/MkTimeline.vue'; import MkTimeline from '@/components/MkTimeline.vue';
import { scroll } from '@/scripts/scroll'; import { scroll } from '@/scripts/scroll';
import * as os from '@/os'; import * as os from '@/os';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
@ -35,7 +35,7 @@ const props = defineProps<{
let antenna = $ref(null); let antenna = $ref(null);
let queue = $ref(0); let queue = $ref(0);
let rootEl = $shallowRef<HTMLElement>(); let rootEl = $shallowRef<HTMLElement>();
let tlEl = $shallowRef<InstanceType<typeof XTimeline>>(); let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
const keymap = $computed(() => ({ const keymap = $computed(() => ({
't': focus, 't': focus,
})); }));

View File

@ -1,12 +1,12 @@
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs" /></template> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="500"> <MkSpacer :content-max="500">
<div v-if="state == 'fetch-session-error'"> <div v-if="state == 'fetch-session-error'">
<p>{{ i18n.ts.somethingHappened }}</p> <p>{{ i18n.ts.somethingHappened }}</p>
</div> </div>
<div v-else-if="$i && !session"> <div v-else-if="$i && !session">
<MkLoading /> <MkLoading/>
</div> </div>
<div v-else-if="$i && session"> <div v-else-if="$i && session">
<XForm <XForm
@ -21,15 +21,16 @@
</div> </div>
<div v-if="state == 'accepted' && session"> <div v-if="state == 'accepted' && session">
<h1>{{ session.app.isAuthorized ? $t('already-authorized') : i18n.ts.allowed }}</h1> <h1>{{ session.app.isAuthorized ? $t('already-authorized') : i18n.ts.allowed }}</h1>
<p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }} <p v-if="session.app.callbackUrl">
<MkEllipsis /> {{ i18n.ts._auth.callback }}
<MkEllipsis/>
</p> </p>
<p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p> <p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p>
</div> </div>
</div> </div>
<div v-else> <div v-else>
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p> <p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
<MkSignin @login="onLogin" /> <MkSignin @login="onLogin"/>
</div> </div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -37,12 +38,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { AuthSession } from 'misskey-js/built/entities';
import XForm from './auth.form.vue'; import XForm from './auth.form.vue';
import MkSignin from '@/components/MkSignin.vue'; import MkSignin from '@/components/MkSignin.vue';
import * as os from '@/os'; import * as os from '@/os';
import { $i, login } from '@/account'; import { $i, login } from '@/account';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { AuthSession } from 'misskey-js/built/entities';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const props = defineProps<{ const props = defineProps<{
@ -82,7 +83,7 @@ onMounted(async () => {
} else { } else {
state = 'waiting'; state = 'waiting';
} }
} catch (e) { } catch (err) {
state = 'fetch-session-error'; state = 'fetch-session-error';
} }
}); });

View File

@ -25,7 +25,7 @@
<MkPostForm v-if="$i" :channel="channel" class="post-form _panel _margin blur" fixed/> <MkPostForm v-if="$i" :channel="channel" class="post-form _panel _margin blur" fixed/>
<XTimeline :key="channelId" class="_margin" src="channel" :channel="channelId" @before="before" @after="after"/> <MkTimeline :key="channelId" class="_margin" src="channel" :channel="channelId" @before="before" @after="after"/>
</div> </div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -34,7 +34,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import MkPostForm from '@/components/MkPostForm.vue'; import MkPostForm from '@/components/MkPostForm.vue';
import XTimeline from '@/components/MkTimeline.vue'; import MkTimeline from '@/components/MkTimeline.vue';
import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import * as os from '@/os'; import * as os from '@/os';
import { useRouter } from '@/router'; import { useRouter } from '@/router';

View File

@ -12,7 +12,7 @@
</div> </div>
</div> </div>
<XNotes :pagination="pagination" :detail="true"/> <MkNotes :pagination="pagination" :detail="true"/>
</div> </div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -21,7 +21,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch, provide } from 'vue'; import { computed, watch, provide } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import XNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import { $i } from '@/account'; import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import * as os from '@/os'; import * as os from '@/os';

View File

@ -4,13 +4,13 @@
<option value="notes">{{ i18n.ts.notes }}</option> <option value="notes">{{ i18n.ts.notes }}</option>
<option value="polls">{{ i18n.ts.poll }}</option> <option value="polls">{{ i18n.ts.poll }}</option>
</MkTab> </MkTab>
<XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> <MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
<XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> <MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
</MkSpacer> </MkSpacer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import XNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import MkTab from '@/components/MkTab.vue'; import MkTab from '@/components/MkTab.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';

View File

@ -0,0 +1,22 @@
<template>
<MkSpacer :content-max="1200">
<div class="_gaps_s">
<MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="false"/>
</div>
</MkSpacer>
</template>
<script lang="ts" setup>
import { } from 'vue';
import MkRolePreview from '@/components/MkRolePreview.vue';
import * as os from '@/os';
let roles = $ref();
os.api('roles/list', {
limit: 30,
}).then(res => {
roles = res;
});
</script>

View File

@ -8,19 +8,19 @@
<template v-if="tag == null"> <template v-if="tag == null">
<MkFoldableSection class="_margin" persist-key="explore-pinned-users"> <MkFoldableSection class="_margin" persist-key="explore-pinned-users">
<template #header><i class="ti ti-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template> <template #header><i class="ti ti-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
<XUserList :pagination="pinnedUsers"/> <MkUserList :pagination="pinnedUsers"/>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection class="_margin" persist-key="explore-popular-users"> <MkFoldableSection class="_margin" persist-key="explore-popular-users">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> <template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<XUserList :pagination="popularUsers"/> <MkUserList :pagination="popularUsers"/>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection class="_margin" persist-key="explore-recently-updated-users"> <MkFoldableSection class="_margin" persist-key="explore-recently-updated-users">
<template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> <template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<XUserList :pagination="recentlyUpdatedUsers"/> <MkUserList :pagination="recentlyUpdatedUsers"/>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection class="_margin" persist-key="explore-recently-registered-users"> <MkFoldableSection class="_margin" persist-key="explore-recently-registered-users">
<template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template> <template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
<XUserList :pagination="recentlyRegisteredUsers"/> <MkUserList :pagination="recentlyRegisteredUsers"/>
</MkFoldableSection> </MkFoldableSection>
</template> </template>
</div> </div>
@ -29,28 +29,28 @@
<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template> <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
<div class="vxjfqztj"> <div class="vxjfqztj">
<MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/user-tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA>
<MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA> <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/user-tags/${tag.tag}`">{{ tag.tag }}</MkA>
</div> </div>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection v-if="tag != null" :key="`${tag}`" class="_margin"> <MkFoldableSection v-if="tag != null" :key="`${tag}`" class="_margin">
<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
<XUserList :pagination="tagUsers"/> <MkUserList :pagination="tagUsers"/>
</MkFoldableSection> </MkFoldableSection>
<template v-if="tag == null"> <template v-if="tag == null">
<MkFoldableSection class="_margin"> <MkFoldableSection class="_margin">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> <template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<XUserList :pagination="popularUsersF"/> <MkUserList :pagination="popularUsersF"/>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection class="_margin"> <MkFoldableSection class="_margin">
<template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> <template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<XUserList :pagination="recentlyUpdatedUsersF"/> <MkUserList :pagination="recentlyUpdatedUsersF"/>
</MkFoldableSection> </MkFoldableSection>
<MkFoldableSection class="_margin"> <MkFoldableSection class="_margin">
<template #header><i class="ti ti-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template> <template #header><i class="ti ti-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template>
<XUserList :pagination="recentlyRegisteredUsersF"/> <MkUserList :pagination="recentlyRegisteredUsersF"/>
</MkFoldableSection> </MkFoldableSection>
</template> </template>
</div> </div>
@ -59,7 +59,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { watch } from 'vue'; import { watch } from 'vue';
import XUserList from '@/components/MkUserList.vue'; import MkUserList from '@/components/MkUserList.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue'; import MkTab from '@/components/MkTab.vue';
import * as os from '@/os'; import * as os from '@/os';

View File

@ -8,6 +8,9 @@
<div v-else-if="tab === 'users'"> <div v-else-if="tab === 'users'">
<XUsers/> <XUsers/>
</div> </div>
<div v-else-if="tab === 'roles'">
<XRoles/>
</div>
<div v-else-if="tab === 'search'"> <div v-else-if="tab === 'search'">
<MkSpacer :content-max="1200"> <MkSpacer :content-max="1200">
<div> <div>
@ -22,7 +25,7 @@
</MkRadios> </MkRadios>
</div> </div>
<XUserList v-if="searchQuery" ref="searchEl" class="_margin" :pagination="searchPagination"/> <MkUserList v-if="searchQuery" ref="searchEl" class="_margin" :pagination="searchPagination"/>
</MkSpacer> </MkSpacer>
</div> </div>
</div> </div>
@ -33,12 +36,13 @@
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import XFeatured from './explore.featured.vue'; import XFeatured from './explore.featured.vue';
import XUsers from './explore.users.vue'; import XUsers from './explore.users.vue';
import XRoles from './explore.roles.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue'; import MkRadios from '@/components/MkRadios.vue';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import XUserList from '@/components/MkUserList.vue'; import MkUserList from '@/components/MkUserList.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
tag?: string; tag?: string;
@ -75,8 +79,13 @@ const headerTabs = $computed(() => [{
key: 'users', key: 'users',
icon: 'ti ti-users', icon: 'ti ti-users',
title: i18n.ts.users, title: i18n.ts.users,
}, {
key: 'roles',
icon: 'ti ti-badges',
title: i18n.ts.roles,
}, { }, {
key: 'search', key: 'search',
icon: 'ti ti-search',
title: i18n.ts.search, title: i18n.ts.search,
}]); }]);

View File

@ -6,7 +6,7 @@
<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> <Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="note" class="note"> <div v-if="note" class="note">
<div v-if="showNext" class="_margin"> <div v-if="showNext" class="_margin">
<XNotes class="" :pagination="nextPagination" :no-gap="true"/> <MkNotes class="" :pagination="nextPagination" :no-gap="true"/>
</div> </div>
<div class="main _margin"> <div class="main _margin">
@ -29,7 +29,7 @@
</div> </div>
<div v-if="showPrev" class="_margin"> <div v-if="showPrev" class="_margin">
<XNotes class="" :pagination="prevPagination" :no-gap="true"/> <MkNotes class="" :pagination="prevPagination" :no-gap="true"/>
</div> </div>
</div> </div>
<MkError v-else-if="error" @retry="fetchNote()"/> <MkError v-else-if="error" @retry="fetchNote()"/>
@ -44,7 +44,7 @@
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import XNoteDetailed from '@/components/MkNoteDetailed.vue'; import XNoteDetailed from '@/components/MkNoteDetailed.vue';
import XNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os'; import * as os from '@/os';

View File

@ -6,10 +6,10 @@
<XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/> <XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/>
</div> </div>
<div v-else-if="tab === 'mentions'"> <div v-else-if="tab === 'mentions'">
<XNotes :pagination="mentionsPagination"/> <MkNotes :pagination="mentionsPagination"/>
</div> </div>
<div v-else-if="tab === 'directNotes'"> <div v-else-if="tab === 'directNotes'">
<XNotes :pagination="directNotesPagination"/> <MkNotes :pagination="directNotesPagination"/>
</div> </div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -19,7 +19,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { notificationTypes } from 'misskey-js'; import { notificationTypes } from 'misskey-js';
import XNotifications from '@/components/MkNotifications.vue'; import XNotifications from '@/components/MkNotifications.vue';
import XNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import * as os from '@/os'; import * as os from '@/os';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';

View File

@ -0,0 +1,47 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :content-max="1200">
<div class="_gaps_s">
<div v-if="role">{{ role.description }}</div>
<MkUserList :pagination="users" :extractor="(item) => item.user"/>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import * as os from '@/os';
import MkUserList from '@/components/MkUserList.vue';
import { definePageMetadata } from '@/scripts/page-metadata';
const props = defineProps<{
role: string;
}>();
let role = $ref();
watch(() => props.role, () => {
os.api('roles/show', {
roleId: props.role,
}).then(res => {
role = res;
});
}, { immediate: true });
const users = $computed(() => ({
endpoint: 'roles/users' as const,
limit: 30,
params: {
roleId: props.role,
},
}));
definePageMetadata(computed(() => ({
title: role?.name,
icon: 'ti ti-badge',
})));
</script>

View File

@ -2,14 +2,14 @@
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="800"> <MkSpacer :content-max="800">
<XNotes ref="notes" :pagination="pagination"/> <MkNotes ref="notes" :pagination="pagination"/>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import XNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import * as os from '@/os'; import * as os from '@/os';

View File

@ -0,0 +1,82 @@
<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://authy.com/" rel="noopener" target="_blank" class="_link">Authy</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>
<MkKeyValue :copy="twoFactorData.url">
<template #key>{{ i18n.ts._2fa.step2Url }}</template>
<template #value>{{ twoFactorData.url }}</template>
</MkKeyValue>
<div class="_buttons">
<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%;
}
</style>

View File

@ -1,183 +1,222 @@
<template> <template>
<div> <FormSection :first="first">
<MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton> <template #label>{{ i18n.ts['2fa'] }}</template>
<template v-if="$i.twoFactorEnabled">
<p>{{ i18n.ts._2fa.alreadyRegistered }}</p>
<MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton>
<template v-if="supportsCredentials"> <div v-if="$i" class="_gaps_s">
<hr class="totp-method-sep"> <MkFolder>
<template #icon><i class="ti ti-shield-lock"></i></template>
<h2 class="heading">{{ i18n.ts.securityKey }}</h2> <template #label>{{ i18n.ts.totp }}</template>
<p>{{ i18n.ts._2fa.securityKeyInfo }}</p> <template #caption>{{ i18n.ts.totpDescription }}</template>
<div class="key-list"> <div v-if="$i.twoFactorEnabled" class="_gaps_s">
<div v-for="key in $i.securityKeysList" class="key"> <div v-text="i18n.ts._2fa.alreadyRegistered"/>
<h3>{{ key.name }}</h3> <template v-if="$i.securityKeysList.length > 0">
<div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> <MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton>
<MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton> <MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
</div> </template>
<MkButton v-else @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
</div> </div>
<MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch> <MkButton v-else-if="!twoFactorData && !$i.twoFactorEnabled" @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
</MkFolder>
<MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo> <MkFolder>
<MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton> <template #icon><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.securityKeyAndPasskey }}</template>
<div class="_gaps_s">
<MkInfo>
{{ i18n.ts._2fa.securityKeyInfo }}<br>
<br>
{{ i18n.ts._2fa.chromePasskeyNotSupported }}
</MkInfo>
<ol v-if="registration && !registration.error"> <MkInfo v-if="!supportsCredentials" warn>
<li v-if="registration.stage >= 0"> {{ i18n.ts._2fa.securityKeyNotSupported }}
{{ i18n.ts.tapSecurityKey }} </MkInfo>
<MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/>
</li> <MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn>
<li v-if="registration.stage >= 1"> {{ i18n.ts._2fa.registerTOTPBeforeKey }}
<MkForm :disabled="registration.stage != 1 || registration.saving"> </MkInfo>
<MkInput v-model="keyName" :max="30">
<template #label>{{ i18n.ts.securityKeyName }}</template> <template v-else>
</MkInput> <MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton>
<MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton> <MkFolder v-for="key in $i.securityKeysList" :key="key.id">
<MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/> <template #label>{{ key.name }}</template>
</MkForm> <template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template>
</li> <div class="_buttons">
</ol> <MkButton @click="renameKey(key)"><i class="ti ti-forms"></i> {{ i18n.ts.rename }}</MkButton>
</template> <MkButton danger @click="unregisterKey(key)"><i class="ti ti-trash"></i> {{ i18n.ts.unregister }}</MkButton>
</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://authy.com/" rel="noopener" target="_blank" class="_link">Authy</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>{{ $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>
</div> </div>
</div> </MkFolder>
</template>
</div>
</MkFolder>
<MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :model-value="usePasswordLessLogin" @update:model-value="v => updatePasswordLessLogin(v)">
<template #label>{{ i18n.ts.passwordLessLogin }}</template>
<template #caption>{{ i18n.ts.passwordLessLoginDescription }}</template>
</MkSwitch>
</div>
</FormSection>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref, defineAsyncComponent } from 'vue';
import { hostname } from '@/config'; import { hostname } from '@/config';
import { byteify, hexify, stringify } from '@/scripts/2fa'; import { byteify, hexify, stringify } from '@/scripts/2fa';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os'; import * as os from '@/os';
import { $i } from '@/account'; import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
// : meUpdatedrefreshAccount
withDefaults(defineProps<{
first?: boolean;
}>(), {
first: false,
});
const twoFactorData = ref<any>(null); const twoFactorData = ref<any>(null);
const supportsCredentials = ref(!!navigator.credentials); const supportsCredentials = ref(!!navigator.credentials);
const usePasswordLessLogin = ref($i!.usePasswordLessLogin); const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
const registration = ref<any>(null);
const keyName = ref('');
const token = ref(null);
function register() { async function registerTOTP() {
os.inputText({ const password = await os.inputText({
title: i18n.ts.password, title: i18n.ts._2fa.registerTOTP,
text: i18n.ts._2fa.passwordToTOTP,
type: 'password', type: 'password',
}).then(({ canceled, result: password }) => { autocomplete: 'current-password',
if (canceled) return;
os.api('i/2fa/register', {
password: password,
}).then(data => {
twoFactorData.value = data;
}); });
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({ os.inputText({
title: i18n.ts.password, title: i18n.ts.password,
type: 'password', type: 'password',
autocomplete: 'current-password',
}).then(({ canceled, result: password }) => { }).then(({ canceled, result: password }) => {
if (canceled) return; if (canceled) return;
os.api('i/2fa/unregister', { os.apiWithDialog('i/2fa/unregister', {
password: password, password: password,
}).then(() => { }).catch(error => {
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 => {
os.alert({ os.alert({
type: 'error', type: 'error',
text: err, text: error,
});
}); });
}); });
} }
function registerKey() { function renewTOTP() {
registration.value.saving = true; os.confirm({
os.api('i/2fa/key-done', { type: 'question',
password: registration.value.password, title: i18n.ts._2fa.renewTOTP,
name: keyName.value, text: i18n.ts._2fa.renewTOTPConfirm,
challengeId: registration.value.challengeId, okText: i18n.ts._2fa.renewTOTPOk,
// we convert each 16 bits to a string to serialise cancelText: i18n.ts._2fa.renewTOTPCancel,
clientDataJSON: stringify(registration.value.credential.response.clientDataJSON), }).then(({ canceled }) => {
attestationObject: hexify(registration.value.credential.response.attestationObject), if (canceled) return;
}).then(key => { registerTOTP();
registration.value = null;
key.lastUsed = new Date();
os.success();
}); });
} }
function unregisterKey(key) { async function unregisterKey(key) {
os.inputText({ 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, title: i18n.ts.password,
type: 'password', type: 'password',
}).then(({ canceled, result: password }) => { autocomplete: 'current-password',
if (canceled) return; });
return os.api('i/2fa/remove-key', { if (password.canceled) return;
password,
await os.apiWithDialog('i/2fa/remove-key', {
password: password.result,
credentialId: key.id, credentialId: key.id,
}).then(() => {
usePasswordLessLogin.value = false;
updatePasswordLessLogin();
}).then(() => {
os.success();
}); });
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,
}); });
} }
function addSecurityKey() { async function addSecurityKey() {
os.inputText({ const password = await os.inputText({
title: i18n.ts.password, title: i18n.ts.password,
type: 'password', type: 'password',
}).then(({ canceled, result: password }) => { autocomplete: 'current-password',
if (canceled) return; });
os.api('i/2fa/register-key', { if (password.canceled) return;
password,
}).then(reg => { const challenge: any = await os.apiWithDialog('i/2fa/register-key', {
registration.value = { password: password.result,
password, });
challengeId: reg!.challengeId,
stage: 0, const name = await os.inputText({
publicKeyOptions: { title: i18n.ts._2fa.registerSecurityKey,
challenge: byteify(reg!.challenge, 'base64'), 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: { rp: {
id: hostname, id: hostname,
name: 'Misskey', name: 'Misskey',
@ -191,26 +230,29 @@ function addSecurityKey() {
timeout: 60000, timeout: 60000,
attestation: 'direct', attestation: 'direct',
}, },
saving: true, }) as Promise<PublicKeyCredential & { response: AuthenticatorAttestationResponse; } | null>;
};
return navigator.credentials.create({ const credential = await os.promiseDialog(
publicKey: registration.value.publicKeyOptions, webAuthnCreation,
}); null,
}).then(credential => { () => {}, // reject
registration.value.credential = credential; i18n.ts._2fa.tapSecurityKey,
registration.value.saving = false; );
registration.value.stage = 1; if (!credential) return;
}).catch(err => {
console.warn('Error while registering?', err); await os.apiWithDialog('i/2fa/key-done', {
registration.value.error = err.message; password: password.result,
registration.value.stage = -1; name: name.result,
}); challengeId: challenge.challengeId,
// we convert each 16 bits to a string to serialise
clientDataJSON: stringify(credential.response.clientDataJSON),
attestationObject: hexify(credential.response.attestationObject),
}); });
} }
async function updatePasswordLessLogin() { async function updatePasswordLessLogin(value: boolean) {
await os.api('i/2fa/password-less', { await os.apiWithDialog('i/2fa/password-less', {
value: !!usePasswordLessLogin.value, value,
}); });
} }
</script> </script>

View File

@ -124,11 +124,11 @@ function saveFields() {
function save() { function save() {
os.apiWithDialog('i/update', { os.apiWithDialog('i/update', {
name: profile.name || null, name: profile.name ?? null,
description: profile.description || null, description: profile.description ?? null,
location: profile.location || null, location: profile.location ?? null,
birthday: profile.birthday || null, birthday: profile.birthday ?? null,
lang: profile.lang || null, lang: profile.lang ?? null,
isBot: !!profile.isBot, isBot: !!profile.isBot,
isCat: !!profile.isCat, isCat: !!profile.isCat,
showTimelineReplies: !!profile.showTimelineReplies, showTimelineReplies: !!profile.showTimelineReplies,

View File

@ -5,10 +5,7 @@
<MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton> <MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton>
</FormSection> </FormSection>
<FormSection>
<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
<X2fa/> <X2fa/>
</FormSection>
<FormSection> <FormSection>
<template #label>{{ i18n.ts.signinHistory }}</template> <template #label>{{ i18n.ts.signinHistory }}</template>
@ -56,18 +53,21 @@ async function change() {
const { canceled: canceled1, result: currentPassword } = await os.inputText({ const { canceled: canceled1, result: currentPassword } = await os.inputText({
title: i18n.ts.currentPassword, title: i18n.ts.currentPassword,
type: 'password', type: 'password',
autocomplete: 'current-password',
}); });
if (canceled1) return; if (canceled1) return;
const { canceled: canceled2, result: newPassword } = await os.inputText({ const { canceled: canceled2, result: newPassword } = await os.inputText({
title: i18n.ts.newPassword, title: i18n.ts.newPassword,
type: 'password', type: 'password',
autocomplete: 'new-password',
}); });
if (canceled2) return; if (canceled2) return;
const { canceled: canceled3, result: newPassword2 } = await os.inputText({ const { canceled: canceled3, result: newPassword2 } = await os.inputText({
title: i18n.ts.newPasswordRetype, title: i18n.ts.newPasswordRetype,
type: 'password', type: 'password',
autocomplete: 'new-password',
}); });
if (canceled3) return; if (canceled3) return;
@ -109,7 +109,7 @@ definePageMetadata({
<style lang="scss" scoped> <style lang="scss" scoped>
.timnmucd { .timnmucd {
padding: 16px; padding: 12px;
&:first-child { &:first-child {
border-top-left-radius: 6px; border-top-left-radius: 6px;

View File

@ -2,14 +2,14 @@
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="800"> <MkSpacer :content-max="800">
<XNotes class="" :pagination="pagination"/> <MkNotes class="" :pagination="pagination"/>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import XNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
const props = defineProps<{ const props = defineProps<{

View File

@ -8,7 +8,7 @@
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
<div :class="$style.tl" class="blur"> <div :class="$style.tl" class="blur">
<XTimeline <MkTimeline
ref="tlComponent" ref="tlComponent"
:key="src" :key="src"
:src="src" :src="src"
@ -24,7 +24,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, computed, watch, provide } from 'vue'; import { defineAsyncComponent, computed, watch, provide } from 'vue';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
import XTimeline from '@/components/MkTimeline.vue'; import MkTimeline from '@/components/MkTimeline.vue';
import MkPostForm from '@/components/MkPostForm.vue'; import MkPostForm from '@/components/MkPostForm.vue';
import { scroll } from '@/scripts/scroll'; import { scroll } from '@/scripts/scroll';
import * as os from '@/os'; import * as os from '@/os';
@ -44,7 +44,7 @@ const keymap = {
't': focus, 't': focus,
}; };
const tlComponent = $shallowRef<InstanceType<typeof XTimeline>>(); const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>();
const rootEl = $shallowRef<HTMLElement>(); const rootEl = $shallowRef<HTMLElement>();
let queue = $ref(0); let queue = $ref(0);

View File

@ -112,7 +112,7 @@
<MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> <MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<div v-for="role in info.roles" :key="role.id" :class="$style.roleItem"> <div v-for="role in info.roles" :key="role.id" :class="$style.roleItem">
<MkRolePreview :class="$style.role" :role="role"/> <MkRolePreview :class="$style.role" :role="role" :for-moderation="true"/>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div> </div>

View File

@ -4,7 +4,7 @@
<div ref="rootEl" class="eqqrhokj"> <div ref="rootEl" class="eqqrhokj">
<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
<div class="tl blur"> <div class="tl blur">
<XTimeline <MkTimeline
ref="tlEl" :key="listId" ref="tlEl" :key="listId"
class="tl" class="tl"
src="list" src="list"
@ -19,7 +19,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import XTimeline from '@/components/MkTimeline.vue'; import MkTimeline from '@/components/MkTimeline.vue';
import { scroll } from '@/scripts/scroll'; import { scroll } from '@/scripts/scroll';
import * as os from '@/os'; import * as os from '@/os';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
@ -34,7 +34,7 @@ const props = defineProps<{
let list = $ref(null); let list = $ref(null);
let queue = $ref(0); let queue = $ref(0);
let tlEl = $shallowRef<InstanceType<typeof XTimeline>>(); let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
let rootEl = $shallowRef<HTMLElement>(); let rootEl = $shallowRef<HTMLElement>();
watch(() => props.listId, async () => { watch(() => props.listId, async () => {

Some files were not shown because too many files have changed in this diff Show More