mirror of
https://github.com/sim1222/misskey.git
synced 2025-04-29 02:37:22 +09:00
Merge remote-tracking branch 'upstream/develop' into develop
This commit is contained in:
commit
ca9e0387cd
1
.github/workflows/lint.yml
vendored
1
.github/workflows/lint.yml
vendored
@ -37,7 +37,6 @@ jobs:
|
||||
- frontend
|
||||
- sw
|
||||
lint:
|
||||
- typecheck
|
||||
- eslint
|
||||
steps:
|
||||
- uses: actions/checkout@v3.3.0
|
||||
|
18
CHANGELOG.md
18
CHANGELOG.md
@ -8,13 +8,27 @@
|
||||
|
||||
You should also include the user name that made the change.
|
||||
-->
|
||||
## 13.x.x (unreleased)
|
||||
## 13.7.0 (2023/02/22)
|
||||
|
||||
### Changes
|
||||
- チャット機能が削除されました
|
||||
|
||||
### Improvements
|
||||
- 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
|
||||
-
|
||||
- ユーザー検索ダイアログでローカルユーザーを絞って検索できない問題を修正
|
||||
- fix(client): MkHeader及びデッキのカラムでチャンネル一覧を選択したとき、最大5個までしか表示されない
|
||||
- 管理画面の広告を10個以上見えるように
|
||||
- Moderation note が保存できない
|
||||
- ユーザーのハッシュタグ検索が機能していないのを修正
|
||||
|
||||
## 13.6.1 (2023/02/12)
|
||||
|
||||
|
@ -5,9 +5,9 @@ Also, the later tasks are more indefinite and are subject to change as developme
|
||||
## (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.
|
||||
|
||||
- Make the number of type errors zero (backend)
|
||||
- ~~Make the number of type errors zero (backend)~~ → Done ✔️
|
||||
- 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
|
||||
- Add more tests
|
||||
- ~~May need to implement a mechanism that allows for DI~~ → Done ✔️
|
||||
|
5
codecov.yml
Normal file
5
codecov.yml
Normal file
@ -0,0 +1,5 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
only_pulls: true
|
@ -103,6 +103,8 @@ renoted: "Renote getätigt."
|
||||
cantRenote: "Renote dieses Beitrags nicht möglich."
|
||||
cantReRenote: "Renote einer Renote nicht möglich."
|
||||
quote: "Zitieren"
|
||||
inChannelRenote: "Kanal-interner Renote"
|
||||
inChannelQuote: "Kanal-internes Zitat"
|
||||
pinnedNote: "Angeheftete Notiz"
|
||||
pinned: "Angeheftet"
|
||||
you: "Du"
|
||||
@ -467,6 +469,8 @@ youHaveNoGroups: "Keine Gruppen vorhanden"
|
||||
joinOrCreateGroup: "Lass dich zu einer Gruppe einladen oder erstelle deine eigene."
|
||||
noHistory: "Kein Verlauf gefunden"
|
||||
signinHistory: "Anmeldungsverlauf"
|
||||
enableAdvancedMfm: "Erweitertes MFM aktivieren"
|
||||
enableAnimatedMfm: "Animiertes MFM aktivieren"
|
||||
doing: "In Bearbeitung …"
|
||||
category: "Kategorie"
|
||||
tags: "Schlagwörter"
|
||||
@ -945,6 +949,14 @@ selectFromPresets: "Aus Vorlagen wählen"
|
||||
achievements: "Errungenschaften"
|
||||
gotInvalidResponseError: "Ungültige Antwort des Servers"
|
||||
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:
|
||||
earnedAt: "Freigeschaltet am"
|
||||
_types:
|
||||
|
@ -103,6 +103,8 @@ renoted: "Renoted."
|
||||
cantRenote: "This post can't be renoted."
|
||||
cantReRenote: "A renote can't be renoted."
|
||||
quote: "Quote"
|
||||
inChannelRenote: "Channel-only Renote"
|
||||
inChannelQuote: "Channel-only Quote"
|
||||
pinnedNote: "Pinned note"
|
||||
pinned: "Pin to profile"
|
||||
you: "You"
|
||||
@ -468,7 +470,7 @@ joinOrCreateGroup: "Get invited to a group or create your own."
|
||||
noHistory: "No history available"
|
||||
signinHistory: "Login history"
|
||||
enableAdvancedMfm: "Enable advanced MFM"
|
||||
enableAnimatedMfm: "Enable MFM with animation"
|
||||
enableAnimatedMfm: "Enable animated MFM"
|
||||
doing: "Processing..."
|
||||
category: "Category"
|
||||
tags: "Tags"
|
||||
@ -951,6 +953,10 @@ thisPostMayBeAnnoying: "This note may annoy others."
|
||||
thisPostMayBeAnnoyingHome: "Post to home timeline"
|
||||
thisPostMayBeAnnoyingCancel: "Cancel"
|
||||
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:
|
||||
earnedAt: "Unlocked at"
|
||||
_types:
|
||||
|
@ -84,12 +84,12 @@ error: "Errore"
|
||||
somethingHappened: "Si è verificato un problema"
|
||||
retry: "Riprova"
|
||||
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."
|
||||
youShouldUpgradeClient: "Per visualizzare la pagina è necessario aggiornare il client alla nuova versione e ricaricare."
|
||||
enterListName: "Nome della lista"
|
||||
privacy: "Privacy"
|
||||
makeFollowManuallyApprove: "Richiedi di approvare i follower manualmente"
|
||||
makeFollowManuallyApprove: "Approva i follower manualmente"
|
||||
defaultNoteVisibility: "Privacy predefinita delle note"
|
||||
follow: "Segui"
|
||||
followRequest: "Richiesta di follow"
|
||||
@ -103,6 +103,8 @@ renoted: "Rinotato!"
|
||||
cantRenote: "È impossibile rinotare questa nota."
|
||||
cantReRenote: "È impossibile rinotare una Rinota."
|
||||
quote: "Cita"
|
||||
inChannelRenote: "Rinota nel canale"
|
||||
inChannelQuote: "Cita nel canale"
|
||||
pinnedNote: "Nota fissata"
|
||||
pinned: "Fissa sul profilo"
|
||||
you: "Tu"
|
||||
@ -129,6 +131,7 @@ unblockConfirm: "Vuoi davvero sbloccare il profilo?"
|
||||
suspendConfirm: "Vuoi sospendere questo profilo?"
|
||||
unsuspendConfirm: "Vuoi revocare la sospensione si questo profilo?"
|
||||
selectList: "Seleziona una lista"
|
||||
selectChannel: "Seleziona canale"
|
||||
selectAntenna: "Scegli un'antenna"
|
||||
selectWidget: "Seleziona il riquadro"
|
||||
editWidgets: "Modifica i riquadri"
|
||||
@ -256,6 +259,8 @@ noMoreHistory: "Non c'è più cronologia da visualizzare"
|
||||
startMessaging: "Nuovo messaggio"
|
||||
nUsersRead: "Letto da {n} persone"
|
||||
agreeTo: "Sono d'accordo con {0}"
|
||||
agreeBelow: "Accetto quanto riportato sotto"
|
||||
basicNotesBeforeCreateAccount: "Note importanti"
|
||||
tos: "Termini di servizio"
|
||||
start: "Inizia!"
|
||||
home: "Home"
|
||||
@ -464,6 +469,8 @@ youHaveNoGroups: "Nessun gruppo"
|
||||
joinOrCreateGroup: "Puoi creare il tuo gruppo o essere invitat@ a gruppi che già esistono."
|
||||
noHistory: "Nessuna cronologia"
|
||||
signinHistory: "Storico degli accessi al profilo"
|
||||
enableAdvancedMfm: "Attiva MFM avanzati"
|
||||
enableAnimatedMfm: "Attiva MFM animati"
|
||||
doing: "In corso..."
|
||||
category: "Categoria"
|
||||
tags: "Tag"
|
||||
@ -860,6 +867,8 @@ failedToFetchAccountInformation: "Impossibile recuperare le informazioni sul pro
|
||||
rateLimitExceeded: "Superato il limite di velocità."
|
||||
cropImage: "Ritaglio dell'immagine"
|
||||
cropImageAsk: "Si desidera ritagliare l'immagine?"
|
||||
cropYes: "Ritaglia"
|
||||
cropNo: "Non ritagliare"
|
||||
file: "Allegati"
|
||||
recentNHours: "Ultime {n} ore"
|
||||
recentNDays: "Ultimi {n} giorni"
|
||||
@ -938,6 +947,16 @@ cannotPerformTemporaryDescription: "L'attività non può essere svolta, poiché
|
||||
preset: "Preimpostato"
|
||||
selectFromPresets: "Seleziona preimpostato"
|
||||
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:
|
||||
earnedAt: "Data di conseguimento"
|
||||
_types:
|
||||
@ -1526,12 +1545,15 @@ _permissions:
|
||||
"read:gallery-likes": "Visualizza i contenuti della galleria."
|
||||
"write:gallery-likes": "Manipolazione dei \"Mi piace\" della galleria."
|
||||
_auth:
|
||||
shareAccessTitle: "Permessi dell'applicazione"
|
||||
shareAccess: "Vuoi autorizzare {name} 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:"
|
||||
pleaseGoBack: "Si prega di ritornare sulla app"
|
||||
callback: "Ritornando sulla app"
|
||||
denied: "Accesso negato"
|
||||
pleaseLogin: "Per favore accedi al tuo account per cambiare i permessi dell'applicazione"
|
||||
_antennaSources:
|
||||
all: "Tutte le note"
|
||||
homeTimeline: "Note dagli utenti che segui"
|
||||
|
@ -394,17 +394,20 @@ userList: "リスト"
|
||||
about: "情報"
|
||||
aboutMisskey: "Misskeyについて"
|
||||
administrator: "管理者"
|
||||
token: "トークン"
|
||||
twoStepAuthentication: "二段階認証"
|
||||
token: "確認コード"
|
||||
2fa: "二要素認証"
|
||||
totp: "認証アプリ"
|
||||
totpDescription: "認証アプリを使ってワンタイムパスワードを入力"
|
||||
moderator: "モデレーター"
|
||||
moderation: "モデレーション"
|
||||
nUsersMentioned: "{n}人が投稿"
|
||||
securityKeyAndPasskey: "セキュリティキー・パスキー"
|
||||
securityKey: "セキュリティキー"
|
||||
securityKeyName: "キーの名前"
|
||||
registerSecurityKey: "セキュリティキーを登録する"
|
||||
lastUsed: "最後の使用"
|
||||
lastUsedAt: "最後の使用: {t}"
|
||||
unregister: "登録を解除"
|
||||
passwordLessLogin: "パスワード無しログイン"
|
||||
passwordLessLogin: "パスワードレスログイン"
|
||||
passwordLessLoginDescription: "パスワードを使用せず、セキュリティキーやパスキーなどのみでログインします"
|
||||
resetPassword: "パスワードをリセット"
|
||||
newPasswordIs: "新しいパスワードは「{password}」です"
|
||||
reduceUiAnimation: "UIのアニメーションを減らす"
|
||||
@ -449,7 +452,6 @@ passwordMatched: "一致しました"
|
||||
passwordNotMatched: "一致していません"
|
||||
signinWith: "{x}でログイン"
|
||||
signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
|
||||
tapSecurityKey: "セキュリティキーにタッチ"
|
||||
or: "もしくは"
|
||||
language: "言語"
|
||||
uiLanguage: "UIの表示言語"
|
||||
@ -946,6 +948,9 @@ collapseRenotes: "見たことのあるRenoteを省略して表示"
|
||||
internalServerError: "サーバー内部エラー"
|
||||
internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。"
|
||||
copyErrorInfo: "エラー情報をコピー"
|
||||
joinThisServer: "このサーバーに登録する"
|
||||
exploreOtherServers: "他のサーバーを探す"
|
||||
letsLookAtTimeline: "タイムラインを見てみる"
|
||||
|
||||
_achievements:
|
||||
earnedAt: "獲得日時"
|
||||
@ -1529,14 +1534,29 @@ _tutorial:
|
||||
|
||||
_2fa:
|
||||
alreadyRegistered: "既に設定は完了しています。"
|
||||
registerDevice: "デバイスを登録"
|
||||
registerKey: "キーを登録"
|
||||
registerTOTP: "認証アプリの設定を開始"
|
||||
passwordToTOTP: "パスワードを入力してください"
|
||||
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
||||
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
||||
step2Url: "デスクトップアプリでは次のURLを入力します:"
|
||||
step3: "アプリに表示されているトークンを入力して完了です。"
|
||||
step4: "これからログインするときも、同じようにトークンを入力します。"
|
||||
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。"
|
||||
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
|
||||
step2Url: "デスクトップアプリでは次のURIを入力します:"
|
||||
step3Title: "確認コードを入力"
|
||||
step3: "アプリに表示されている確認コード(トークン)を入力して完了です。"
|
||||
step4: "これからログインするときも、同じように確認コードを入力します。"
|
||||
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
|
||||
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
|
||||
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
|
||||
chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。"
|
||||
registerSecurityKey: "セキュリティキー・パスキーを登録する"
|
||||
securityKeyName: "キーの名前を入力"
|
||||
tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"
|
||||
removeKey: "セキュリティキーを削除"
|
||||
removeKeyConfirm: "{name}を削除しますか?"
|
||||
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
|
||||
renewTOTP: "認証アプリを再設定"
|
||||
renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります"
|
||||
renewTOTPOk: "再設定する"
|
||||
renewTOTPCancel: "やめておく"
|
||||
|
||||
_permissions:
|
||||
"read:account": "アカウントの情報を見る"
|
||||
@ -1871,3 +1891,7 @@ _deck:
|
||||
channel: "チャンネル"
|
||||
mentions: "あなた宛て"
|
||||
direct: "ダイレクト"
|
||||
|
||||
_dialog:
|
||||
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
||||
charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}"
|
||||
|
@ -103,6 +103,8 @@ renoted: "Renoteしたで。"
|
||||
cantRenote: "この投稿はRenoteできへんらしい。"
|
||||
cantReRenote: "Renote自体はRenoteできへんで。"
|
||||
quote: "引用"
|
||||
inChannelRenote: "チャンネル内Renote"
|
||||
inChannelQuote: "チャンネル内引用"
|
||||
pinnedNote: "ピン留めされとるノート"
|
||||
pinned: "ピン留めしとく"
|
||||
you: "あんた"
|
||||
@ -948,35 +950,168 @@ achievements: "実績"
|
||||
gotInvalidResponseError: "サーバー黙っとるわ、知らんけど"
|
||||
gotInvalidResponseErrorDescription: "サーバーいま日曜日。またきて月曜日。"
|
||||
thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。"
|
||||
thisPostMayBeAnnoyingHome: "ホームに投稿"
|
||||
thisPostMayBeAnnoyingCancel: "やめとく"
|
||||
thisPostMayBeAnnoyingIgnore: "このまま投稿"
|
||||
collapseRenotes: "見たことあるRenoteは省略やで"
|
||||
internalServerError: "サーバー内部エラー"
|
||||
internalServerErrorDescription: "サーバー内部でよう分からんエラーやわ"
|
||||
copyErrorInfo: "エラー情報をコピー"
|
||||
_achievements:
|
||||
earnedAt: "貰った日ぃ"
|
||||
_types:
|
||||
_notes1:
|
||||
title: "まいど!"
|
||||
description: "初めてノート投稿したった"
|
||||
flavor: "Misskeyを楽しんでな~"
|
||||
_notes10:
|
||||
title: "ノートの天保山"
|
||||
description: "ノートを10回投稿した"
|
||||
_notes100:
|
||||
title: "ノートの真田山"
|
||||
description: "ノートを100回投稿した"
|
||||
_notes500:
|
||||
title: "ノートの生駒山"
|
||||
description: "ノートを500回投稿した"
|
||||
_notes1000:
|
||||
title: "ノートの山"
|
||||
description: "ノートを1,000回投稿した"
|
||||
_notes5000:
|
||||
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:
|
||||
title: "ビギナーⅠ"
|
||||
description: "通算ログイン日数が3日"
|
||||
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:
|
||||
title: "Misskey好きやねん"
|
||||
description: "\"I ❤ #Misskey\"を投稿した"
|
||||
flavor: "Misskeyを使ってくれてありがとうな~ by 開発チーム"
|
||||
_foundTreasure:
|
||||
title: "なんでも鑑定団"
|
||||
description: "隠されたお宝を発見した"
|
||||
_client30min:
|
||||
title: "ねんね"
|
||||
description: "クライアントを起動してから30分以上経過した"
|
||||
_noteDeletedWithin1min:
|
||||
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:
|
||||
title: "マド開けすぎ"
|
||||
description: "ウィンドウを3つ以上開いた状態にした"
|
||||
_driveFolderCircularReference:
|
||||
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:
|
||||
new: "ロールの作成"
|
||||
edit: "ロールの編集"
|
||||
|
@ -1113,6 +1113,8 @@ _achievements:
|
||||
_loggedInOnNewYearsDay:
|
||||
title: "З Новим роком!"
|
||||
description: "Увійшли в перший день року"
|
||||
_cookieClicked:
|
||||
flavor: "Чекайте, це вірний сайт?"
|
||||
_brainDiver:
|
||||
title: "Brain Diver"
|
||||
description: "Відправити посилання на \"Brain Diver\""
|
||||
|
@ -103,6 +103,8 @@ renoted: "已转发。"
|
||||
cantRenote: "该帖无法转发。"
|
||||
cantReRenote: "转发无法被再次转发。"
|
||||
quote: "引用"
|
||||
inChannelRenote: "在频道内转发"
|
||||
inChannelQuote: "在频道内引用"
|
||||
pinnedNote: "已置顶的帖子"
|
||||
pinned: "置顶"
|
||||
you: "您"
|
||||
@ -951,6 +953,10 @@ thisPostMayBeAnnoying: "这个帖子可能会让其他人感到困扰。"
|
||||
thisPostMayBeAnnoyingHome: "发到首页"
|
||||
thisPostMayBeAnnoyingCancel: "取消"
|
||||
thisPostMayBeAnnoyingIgnore: "就这样发布"
|
||||
collapseRenotes: "省略显示已经看过的转发内容"
|
||||
internalServerError: "内部服务器错误"
|
||||
internalServerErrorDescription: "内部服务器发生了预期外的错误"
|
||||
copyErrorInfo: "复制错误信息"
|
||||
_achievements:
|
||||
earnedAt: "达成时间"
|
||||
_types:
|
||||
|
@ -103,6 +103,8 @@ renoted: "轉傳成功"
|
||||
cantRenote: "無法轉發此貼文。"
|
||||
cantReRenote: "無法轉傳之前已經轉傳過的內容。"
|
||||
quote: "引用"
|
||||
inChannelRenote: "在頻道內轉發"
|
||||
inChannelQuote: "在頻道內引用"
|
||||
pinnedNote: "已置頂的貼文"
|
||||
pinned: "置頂"
|
||||
you: "您"
|
||||
@ -952,6 +954,9 @@ thisPostMayBeAnnoyingHome: "發布到首頁"
|
||||
thisPostMayBeAnnoyingCancel: "退出"
|
||||
thisPostMayBeAnnoyingIgnore: "直接發布貼文"
|
||||
collapseRenotes: "省略顯示已看過的轉發貼文"
|
||||
internalServerError: "內部伺服器錯誤"
|
||||
internalServerErrorDescription: "內部伺服器發生了非預期的錯誤。"
|
||||
copyErrorInfo: "複製錯誤資訊"
|
||||
_achievements:
|
||||
earnedAt: "獲得日期"
|
||||
_types:
|
||||
|
12
package.json
12
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "13.6.1-simkey",
|
||||
"version": "13.7.0-simkey",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -54,12 +54,12 @@
|
||||
"devDependencies": {
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.51.0",
|
||||
"@typescript-eslint/parser": "5.51.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.52.0",
|
||||
"@typescript-eslint/parser": "5.52.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.5.1",
|
||||
"eslint": "8.33.0",
|
||||
"start-server-and-test": "1.15.3"
|
||||
"cypress": "12.6.0",
|
||||
"eslint": "8.34.0",
|
||||
"start-server-and-test": "1.15.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs-core": "4.2.0"
|
||||
|
@ -25,30 +25,30 @@
|
||||
"@tensorflow/tfjs-node": "4.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "4.11.1",
|
||||
"@bull-board/fastify": "4.11.1",
|
||||
"@bull-board/ui": "4.11.1",
|
||||
"@bull-board/api": "4.12.1",
|
||||
"@bull-board/fastify": "4.12.1",
|
||||
"@bull-board/ui": "4.12.1",
|
||||
"@discordapp/twemoji": "14.0.2",
|
||||
"@fastify/accepts": "4.1.0",
|
||||
"@fastify/cookie": "8.3.0",
|
||||
"@fastify/cors": "8.2.0",
|
||||
"@fastify/http-proxy": "8.4.0",
|
||||
"@fastify/multipart": "7.4.0",
|
||||
"@fastify/static": "6.8.0",
|
||||
"@fastify/multipart": "7.4.1",
|
||||
"@fastify/static": "6.9.0",
|
||||
"@fastify/view": "7.4.1",
|
||||
"@nestjs/common": "9.3.7",
|
||||
"@nestjs/core": "9.3.7",
|
||||
"@nestjs/testing": "9.3.7",
|
||||
"@nestjs/common": "9.3.9",
|
||||
"@nestjs/core": "9.3.9",
|
||||
"@nestjs/testing": "9.3.9",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sinonjs/fake-timers": "10.0.2",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "5.3.1",
|
||||
"autwh": "0.1.0",
|
||||
"aws-sdk": "2.1295.0",
|
||||
"aws-sdk": "2.1318.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.4",
|
||||
"bull": "4.10.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bull": "4.10.4",
|
||||
"cacheable-lookup": "6.1.0",
|
||||
"cbor": "8.1.0",
|
||||
"chalk": "5.2.0",
|
||||
@ -60,12 +60,13 @@
|
||||
"date-fns": "2.29.3",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"escape-regexp": "0.0.1",
|
||||
"fastify": "4.12.0",
|
||||
"fastify": "4.13.0",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "18.2.0",
|
||||
"file-type": "18.2.1",
|
||||
"fluent-ffmpeg": "2.1.2",
|
||||
"form-data": "4.0.0",
|
||||
"got": "12.5.3",
|
||||
"happy-dom": "^8.7.0",
|
||||
"hpagent": "1.2.0",
|
||||
"ioredis": "4.28.5",
|
||||
"ip-cidr": "3.1.0",
|
||||
@ -85,6 +86,7 @@
|
||||
"nsfwjs": "2.4.2",
|
||||
"oauth": "0.10.0",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "^9.0.2",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.9.0",
|
||||
"private-ip": "3.0.0",
|
||||
@ -104,15 +106,14 @@
|
||||
"rss-parser": "3.12.0",
|
||||
"rxjs": "7.8.0",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.9.0",
|
||||
"sanitize-html": "2.10.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"semver": "7.3.8",
|
||||
"sharp": "0.31.3",
|
||||
"speakeasy": "2.0.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"systeminformation": "5.17.8",
|
||||
"systeminformation": "5.17.9",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
@ -126,14 +127,14 @@
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.5.0",
|
||||
"websocket": "1.0.34",
|
||||
"ws": "8.12.0",
|
||||
"ws": "8.12.1",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.4.2",
|
||||
"@jest/globals": "29.4.3",
|
||||
"@redocly/openapi-core": "1.0.0-beta.123",
|
||||
"@swc/cli": "0.1.61",
|
||||
"@swc/core": "1.3.34",
|
||||
"@swc/cli": "0.1.62",
|
||||
"@swc/core": "1.3.35",
|
||||
"@swc/jest": "0.2.24",
|
||||
"@types/accepts": "1.3.5",
|
||||
"@types/archiver": "5.3.1",
|
||||
@ -151,7 +152,7 @@
|
||||
"@types/jsonld": "1.5.8",
|
||||
"@types/jsrsasign": "10.5.5",
|
||||
"@types/mime-types": "2.1.1",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/node": "18.14.0",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/oauth": "0.9.1",
|
||||
@ -167,7 +168,6 @@
|
||||
"@types/semver": "7.3.13",
|
||||
"@types/sharp": "0.31.1",
|
||||
"@types/sinonjs__fake-timers": "8.1.2",
|
||||
"@types/speakeasy": "2.0.7",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@types/unzipper": "0.10.5",
|
||||
@ -176,13 +176,13 @@
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.51.0",
|
||||
"@typescript-eslint/parser": "5.51.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.52.0",
|
||||
"@typescript-eslint/parser": "5.52.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.33.0",
|
||||
"eslint": "8.34.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"execa": "6.1.0",
|
||||
"jest": "29.4.2",
|
||||
"jest-mock": "29.4.2"
|
||||
"jest": "29.4.3",
|
||||
"jest-mock": "29.4.3"
|
||||
}
|
||||
}
|
||||
|
8
packages/backend/src/@types/redis-lock.d.ts
vendored
Normal file
8
packages/backend/src/@types/redis-lock.d.ts
vendored
Normal 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;
|
||||
}
|
@ -12,7 +12,7 @@ const retryDelay = 100;
|
||||
|
||||
@Injectable()
|
||||
export class AppLockService {
|
||||
private lock: (key: string, timeout?: number) => Promise<() => void>;
|
||||
private lock: (key: string, timeout?: number, _?: (() => Promise<void>) | undefined) => Promise<() => void>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as parse5 from 'parse5';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { Window } from 'happy-dom';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { intersperse } from '@/misc/prelude/array.js';
|
||||
@ -235,7 +235,7 @@ export class MfmService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { window } = new JSDOM('');
|
||||
const { window } = new Window();
|
||||
|
||||
const doc = window.document;
|
||||
|
||||
@ -300,7 +300,7 @@ export class MfmService {
|
||||
|
||||
hashtag: (node) => {
|
||||
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.setAttribute('rel', 'tag');
|
||||
return a;
|
||||
@ -326,7 +326,7 @@ export class MfmService {
|
||||
|
||||
link: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = node.props.url;
|
||||
a.setAttribute('href', node.props.url);
|
||||
appendChildren(node.children, a);
|
||||
return a;
|
||||
},
|
||||
@ -335,7 +335,7 @@ export class MfmService {
|
||||
const a = doc.createElement('a');
|
||||
const { username, host, acct } = node.props;
|
||||
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.textContent = acct;
|
||||
return a;
|
||||
@ -360,14 +360,14 @@ export class MfmService {
|
||||
|
||||
url: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = node.props.url;
|
||||
a.setAttribute('href', node.props.url);
|
||||
a.textContent = node.props.url;
|
||||
return a;
|
||||
},
|
||||
|
||||
search: (node) => {
|
||||
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;
|
||||
return a;
|
||||
},
|
||||
|
@ -9,7 +9,7 @@ import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
// Defined also packages/sw/types.ts#L13
|
||||
type pushNotificationsTypes = {
|
||||
type PushNotificationsTypes = {
|
||||
'notification': Packed<'Notification'>;
|
||||
'unreadAntennaNote': {
|
||||
antenna: { id: string, name: string };
|
||||
@ -22,7 +22,7 @@ type pushNotificationsTypes = {
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
return {
|
||||
@ -56,7 +56,7 @@ export class PushNotificationService {
|
||||
}
|
||||
|
||||
@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();
|
||||
|
||||
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
|
||||
|
@ -452,7 +452,7 @@ export class ApInboxService {
|
||||
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: actor.id });
|
||||
if (user.isDeleted) {
|
||||
this.logger.info('skip: already deleted');
|
||||
return 'skip: already deleted';
|
||||
}
|
||||
|
||||
const job = await this.queueService.createDeleteAccountJob(actor);
|
||||
|
@ -1,8 +1,8 @@
|
||||
export type obj = { [x: string]: any };
|
||||
export type Obj = { [x: string]: any };
|
||||
export type ApObject = IObject | string | (IObject | string)[];
|
||||
|
||||
export interface IObject {
|
||||
'@context'?: string | string[] | obj | obj[];
|
||||
'@context'?: string | string[] | Obj | Obj[];
|
||||
type: string | string[];
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
|
@ -25,14 +25,7 @@ export class RoleEntityService {
|
||||
public async pack(
|
||||
src: Role['id'] | Role,
|
||||
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 assigns = await this.roleAssignmentsRepository.findBy({
|
||||
@ -65,9 +58,6 @@ export class RoleEntityService {
|
||||
canEditMembersByModerator: role.canEditMembersByModerator,
|
||||
policies: policies,
|
||||
usersCount: assigns.length,
|
||||
...(opts.detail ? {
|
||||
users: this.userEntityService.packMany(assigns.map(x => x.userId), me),
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@ -75,11 +65,8 @@ export class RoleEntityService {
|
||||
public packMany(
|
||||
roles: any[],
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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_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_users from './endpoints/admin/roles/users.js';
|
||||
import * as ep___announcements from './endpoints/announcements.js';
|
||||
import * as ep___antennas_create from './endpoints/antennas/create.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_registerKey from './endpoints/i/2fa/register-key.js';
|
||||
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
|
||||
import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js';
|
||||
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
||||
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
||||
import * as ep___i_apps from './endpoints/i/apps.js';
|
||||
@ -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___pinnedUsers from './endpoints/pinned-users.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___resetDb from './endpoints/reset-db.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_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_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.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_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_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_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_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 };
|
||||
@ -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 $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.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 $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.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_unassign,
|
||||
$admin_roles_updateDefaultPolicies,
|
||||
$admin_roles_users,
|
||||
$announcements,
|
||||
$antennas_create,
|
||||
$antennas_delete,
|
||||
@ -806,6 +817,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$i_2fa_passwordLess,
|
||||
$i_2fa_registerKey,
|
||||
$i_2fa_register,
|
||||
$i_2fa_updateKey,
|
||||
$i_2fa_removeKey,
|
||||
$i_2fa_unregister,
|
||||
$i_apps,
|
||||
@ -912,6 +924,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$ping,
|
||||
$pinnedUsers,
|
||||
$promo_read,
|
||||
$roles_list,
|
||||
$roles_show,
|
||||
$roles_users,
|
||||
$requestResetPassword,
|
||||
$resetDb,
|
||||
$resetPassword,
|
||||
@ -1016,6 +1031,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$admin_roles_assign,
|
||||
$admin_roles_unassign,
|
||||
$admin_roles_updateDefaultPolicies,
|
||||
$admin_roles_users,
|
||||
$announcements,
|
||||
$antennas_create,
|
||||
$antennas_delete,
|
||||
@ -1120,6 +1136,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$i_2fa_passwordLess,
|
||||
$i_2fa_registerKey,
|
||||
$i_2fa_register,
|
||||
$i_2fa_updateKey,
|
||||
$i_2fa_removeKey,
|
||||
$i_2fa_unregister,
|
||||
$i_apps,
|
||||
@ -1226,6 +1243,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$ping,
|
||||
$pinnedUsers,
|
||||
$promo_read,
|
||||
$roles_list,
|
||||
$roles_show,
|
||||
$roles_users,
|
||||
$requestResetPassword,
|
||||
$resetDb,
|
||||
$resetPassword,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as speakeasy from 'speakeasy';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.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({
|
||||
secret: profile.twoFactorSecret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
window: 2,
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
return this.signinService.signin(request, reply, user);
|
||||
} else {
|
||||
if (delta === null) {
|
||||
return await fail(403, {
|
||||
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
|
||||
});
|
||||
} else {
|
||||
return this.signinService.signin(request, reply, user);
|
||||
}
|
||||
} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
|
||||
if (!same && !profile.usePasswordLessLogin) {
|
||||
|
@ -20,14 +20,14 @@ type File = {
|
||||
};
|
||||
|
||||
// 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) =>
|
||||
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
|
||||
|
||||
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>;
|
||||
|
||||
constructor(meta: T, paramDef: Ps, cb: executor<T, Ps>) {
|
||||
constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) {
|
||||
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) => {
|
||||
|
@ -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_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_users from './endpoints/admin/roles/users.js';
|
||||
import * as ep___announcements from './endpoints/announcements.js';
|
||||
import * as ep___antennas_create from './endpoints/antennas/create.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_registerKey from './endpoints/i/2fa/register-key.js';
|
||||
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
|
||||
import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js';
|
||||
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
||||
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
||||
import * as ep___i_apps from './endpoints/i/apps.js';
|
||||
@ -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___pinnedUsers from './endpoints/pinned-users.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___resetDb from './endpoints/reset-db.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/unassign', ep___admin_roles_unassign],
|
||||
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
|
||||
['admin/roles/users', ep___admin_roles_users],
|
||||
['announcements', ep___announcements],
|
||||
['antennas/create', ep___antennas_create],
|
||||
['antennas/delete', ep___antennas_delete],
|
||||
@ -484,6 +490,7 @@ const eps = [
|
||||
['i/2fa/password-less', ep___i_2fa_passwordLess],
|
||||
['i/2fa/register-key', ep___i_2fa_registerKey],
|
||||
['i/2fa/register', ep___i_2fa_register],
|
||||
['i/2fa/update-key', ep___i_2fa_updateKey],
|
||||
['i/2fa/remove-key', ep___i_2fa_removeKey],
|
||||
['i/2fa/unregister', ep___i_2fa_unregister],
|
||||
['i/apps', ep___i_apps],
|
||||
@ -590,6 +597,9 @@ const eps = [
|
||||
['ping', ep___ping],
|
||||
['pinned-users', ep___pinnedUsers],
|
||||
['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],
|
||||
['reset-db', ep___resetDb],
|
||||
['reset-password', ep___resetPassword],
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
@ -161,6 +161,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
id: file.id,
|
||||
@ -207,8 +215,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
name: file.name,
|
||||
md5: file.md5,
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
requestIp: isModerator ? file.requestIp : null,
|
||||
requestHeaders: isModerator ? file.requestHeaders : null,
|
||||
requestIp: iAmModerator ? file.requestIp : null,
|
||||
requestHeaders: iAmModerator && !ownerIsModerator ? file.requestHeaders : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const roles = await this.rolesRepository.find({
|
||||
order: { lastUsedAt: 'DESC' },
|
||||
});
|
||||
return await this.roleEntityService.packMany(roles, me, { detail: false });
|
||||
return await this.roleEntityService.packMany(roles, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -39,12 +39,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
private roleEntityService: RoleEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps) => {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
|
||||
if (role == null) {
|
||||
throw new ApiError(meta.errors.noSuchRole);
|
||||
}
|
||||
return await this.roleEntityService.pack(role);
|
||||
return await this.roleEntityService.pack(role, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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 }),
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
@ -59,12 +59,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
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 roles = await this.roleService.getUserRoles(user.id);
|
||||
@ -89,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
moderationNote: profile.moderationNote,
|
||||
signins,
|
||||
policies: await this.roleService.getUserPolicies(user.id),
|
||||
roles: await this.roleEntityService.packMany(roles, me, { detail: false }),
|
||||
roles: await this.roleEntityService.packMany(roles, me),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -1,7 +1,10 @@
|
||||
import * as speakeasy from 'speakeasy';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.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';
|
||||
|
||||
export const meta = {
|
||||
@ -22,8 +25,14 @@ export const paramDef = {
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const token = ps.token.replace(/\s/g, '');
|
||||
@ -34,13 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
throw new Error('二段階認証の設定が開始されていません');
|
||||
}
|
||||
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorTempSecret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
if (delta === null) {
|
||||
throw new Error('not verified');
|
||||
}
|
||||
|
||||
@ -48,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
twoFactorSecret: profile.twoFactorTempSecret,
|
||||
twoFactorEnabled: true,
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ export const paramDef = {
|
||||
attestationObject: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
challengeId: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
name: { type: 'string', minLength: 1, maxLength: 30 },
|
||||
},
|
||||
required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
|
||||
} as const;
|
||||
|
@ -1,12 +1,23 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
noKey: {
|
||||
message: 'No security key.',
|
||||
code: 'NO_SECURITY_KEY',
|
||||
id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
@ -23,11 +34,45 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
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, {
|
||||
usePasswordLessLogin: ps.value,
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as speakeasy from 'speakeasy';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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
|
||||
const secret = speakeasy.generateSecret({
|
||||
length: 32,
|
||||
});
|
||||
const secret = new OTPAuth.Secret();
|
||||
|
||||
await this.userProfilesRepository.update(me.id, {
|
||||
twoFactorTempSecret: secret.base32,
|
||||
});
|
||||
|
||||
// Get the data URL of the authenticator URL
|
||||
const url = speakeasy.otpauthURL({
|
||||
secret: secret.base32,
|
||||
encoding: 'base32',
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret,
|
||||
digits: 6,
|
||||
label: me.username,
|
||||
issuer: this.config.host,
|
||||
});
|
||||
const dataUrl = await QRCode.toDataURL(url);
|
||||
const url = totp.toString();
|
||||
const qr = await QRCode.toDataURL(url);
|
||||
|
||||
return {
|
||||
qr: dataUrl,
|
||||
qr,
|
||||
url,
|
||||
secret: secret.base32,
|
||||
label: me.username,
|
||||
|
@ -50,6 +50,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
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
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||
detail: true,
|
||||
|
@ -1,7 +1,9 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { UserProfilesRepository } from '@/models/index.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
@ -24,6 +26,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
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, {
|
||||
twoFactorSecret: null,
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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 {};
|
||||
});
|
||||
}
|
||||
}
|
37
packages/backend/src/server/api/endpoints/roles/list.ts
Normal file
37
packages/backend/src/server/api/endpoints/roles/list.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
52
packages/backend/src/server/api/endpoints/roles/show.ts
Normal file
52
packages/backend/src/server/api/endpoints/roles/show.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
71
packages/backend/src/server/api/endpoints/roles/users.ts
Normal file
71
packages/backend/src/server/api/endpoints/roles/users.ts
Normal 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 }),
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
@ -36,13 +37,13 @@ export const paramDef = {
|
||||
properties: {
|
||||
username: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['username']
|
||||
required: ['username'],
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
host: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['host']
|
||||
required: ['host'],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
@ -53,6 +54,9 @@ export const paramDef = {
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@ -62,79 +66,76 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
|
||||
|
||||
if (ps.host) {
|
||||
const q = this.usersRepository.createQueryBuilder('user')
|
||||
.where('user.isSuspended = FALSE')
|
||||
.andWhere('user.host LIKE :host', { host: sqlLikeEscape(ps.host.toLowerCase()) + '%' });
|
||||
|
||||
const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => {
|
||||
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');
|
||||
q.orderBy('user.updatedAt', 'DESC');
|
||||
|
||||
const users = await q.take(ps.limit).getMany();
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { detail: ps.detail });
|
||||
} else if (ps.username) {
|
||||
let users: User[] = [];
|
||||
|
||||
if (me) {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
const query = this.usersRepository.createQueryBuilder('user')
|
||||
.where(`user.id IN (${ followingQuery.getQuery() })`)
|
||||
.andWhere('user.id != :meId', { meId: me.id })
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
}));
|
||||
|
||||
query.setParameters(followingQuery.getParameters());
|
||||
|
||||
users = await query
|
||||
.orderBy('user.usernameLower', 'ASC')
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
if (users.length < ps.limit) {
|
||||
const otherQuery = await this.usersRepository.createQueryBuilder('user')
|
||||
.where(`user.id NOT IN (${ followingQuery.getQuery() })`)
|
||||
.andWhere('user.id != :meId', { meId: me.id })
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
|
||||
.andWhere('user.updatedAt IS NOT NULL');
|
||||
|
||||
otherQuery.setParameters(followingQuery.getParameters());
|
||||
|
||||
const otherUsers = await otherQuery
|
||||
.orderBy('user.updatedAt', 'DESC')
|
||||
.take(ps.limit - users.length)
|
||||
.getMany();
|
||||
|
||||
users = users.concat(otherUsers);
|
||||
if (ps.host) {
|
||||
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()) + '%',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
users = await this.usersRepository.createQueryBuilder('user')
|
||||
.where('user.isSuspended = FALSE')
|
||||
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
|
||||
.andWhere('user.updatedAt IS NOT NULL')
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
|
||||
|
||||
let users: User[] = [];
|
||||
|
||||
if (me) {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
const query = setUsernameAndHostQuery()
|
||||
.andWhere(`user.id IN (${ followingQuery.getQuery() })`)
|
||||
.andWhere('user.id != :meId', { meId: me.id })
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
}));
|
||||
|
||||
query.setParameters(followingQuery.getParameters());
|
||||
|
||||
users = await query
|
||||
.orderBy('user.usernameLower', 'ASC')
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
if (users.length < ps.limit) {
|
||||
const otherQuery = setUsernameAndHostQuery()
|
||||
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`)
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere('user.updatedAt IS NOT NULL');
|
||||
|
||||
otherQuery.setParameters(followingQuery.getParameters());
|
||||
|
||||
const otherUsers = await otherQuery
|
||||
.orderBy('user.updatedAt', 'DESC')
|
||||
.take(ps.limit - users.length)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { detail: !!ps.detail });
|
||||
users = users.concat(otherUsers);
|
||||
}
|
||||
} else {
|
||||
const query = setUsernameAndHostQuery()
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere('user.updatedAt IS NOT NULL');
|
||||
|
||||
users = await query
|
||||
.orderBy('user.updatedAt', 'DESC')
|
||||
.take(ps.limit - users.length)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
return [];
|
||||
return await this.userEntityService.packMany(users, me, { detail: !!ps.detail });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
||||
*/
|
102
packages/backend/test/unit/MfmService.ts
Normal file
102
packages/backend/test/unit/MfmService.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
92
packages/backend/test/unit/ReactionService.ts
Normal file
92
packages/backend/test/unit/ReactionService.ts
Normal 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'), '👍');
|
||||
});
|
||||
});
|
||||
});
|
@ -19,7 +19,7 @@ import Logger from '@/logger.js';
|
||||
describe('Chart', () => {
|
||||
const config = loadConfig();
|
||||
const appLockService = {
|
||||
getChartInsertLock: jest.fn().mockImplementation(() => Promise.resolve(() => {})),
|
||||
getChartInsertLock: () => () => Promise.resolve(() => {}),
|
||||
} as unknown as jest.Mocked<AppLockService>;
|
||||
|
||||
let db: DataSource | undefined;
|
||||
|
@ -1,11 +1,11 @@
|
||||
import * as assert from 'assert';
|
||||
|
||||
import { parse } from 'mfm-js';
|
||||
import { extractMentions } from '../../src/misc/extract-mentions.js';
|
||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||
|
||||
describe('Extract mentions', () => {
|
||||
test('simple', () => {
|
||||
const ast = parse('@foo @bar @baz')!;
|
||||
const ast = parse('@foo @bar @baz');
|
||||
const mentions = extractMentions(ast);
|
||||
assert.deepStrictEqual(mentions, [{
|
||||
username: 'foo',
|
||||
@ -23,7 +23,7 @@ describe('Extract mentions', () => {
|
||||
});
|
||||
|
||||
test('nested', () => {
|
||||
const ast = parse('@foo **@bar** @baz')!;
|
||||
const ast = parse('@foo **@bar** @baz');
|
||||
const mentions = extractMentions(ast);
|
||||
assert.deepStrictEqual(mentions, [{
|
||||
username: 'foo',
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"noEmitOnError": false,
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedParameters": false,
|
||||
|
@ -55,6 +55,7 @@ module.exports = {
|
||||
'vue/multi-word-component-names': 'warn',
|
||||
'vue/require-v-for-key': 'warn',
|
||||
'vue/no-unused-components': 'warn',
|
||||
'vue/no-unused-vars': 'warn',
|
||||
'vue/valid-v-for': 'warn',
|
||||
'vue/return-in-computed-property': 'warn',
|
||||
'vue/no-setup-props-destructure': 'warn',
|
||||
|
@ -19,11 +19,11 @@
|
||||
"@vue/compiler-sfc": "3.2.47",
|
||||
"autobind-decorator": "2.4.0",
|
||||
"autosize": "5.0.2",
|
||||
"blurhash": "2.0.4",
|
||||
"blurhash": "2.0.5",
|
||||
"broadcast-channel": "4.20.2",
|
||||
"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
||||
"canvas-confetti": "1.6.0",
|
||||
"chart.js": "4.2.0",
|
||||
"chart.js": "4.2.1",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
@ -38,7 +38,7 @@
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"matter-js": "0.18.0",
|
||||
"matter-js": "0.19.0",
|
||||
"mfm-js": "git+https://github.com/sim1222/mfm.js.git",
|
||||
"misetehoshii": "https://github.com/melt-adzuki/misetehoshii",
|
||||
"misskey-js": "0.0.15",
|
||||
@ -47,13 +47,12 @@
|
||||
"punycode": "2.3.0",
|
||||
"querystring": "0.2.1",
|
||||
"rndstr": "1.0.0",
|
||||
"rollup": "3.14.0",
|
||||
"rollup": "3.17.2",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.9.0",
|
||||
"sass": "1.58.0",
|
||||
"sanitize-html": "2.10.0",
|
||||
"sass": "1.58.3",
|
||||
"seedrandom": "3.0.5",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"syuilo-password-strength": "0.0.1",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.149.0",
|
||||
@ -66,7 +65,7 @@
|
||||
"uuid": "9.0.0",
|
||||
"vanilla-tilt": "1.8.0",
|
||||
"vue-plyr": "7.0.0",
|
||||
"vite": "4.1.1",
|
||||
"vite": "4.1.2",
|
||||
"vue": "3.2.47",
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"vuedraggable": "next"
|
||||
@ -77,7 +76,7 @@
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@types/matter-js": "0.18.2",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/node": "18.14.0",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/sanitize-html": "2.8.0",
|
||||
"@types/seedrandom": "3.0.4",
|
||||
@ -86,16 +85,16 @@
|
||||
"@types/uuid": "9.0.0",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.51.0",
|
||||
"@typescript-eslint/parser": "5.51.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.52.0",
|
||||
"@typescript-eslint/parser": "5.52.0",
|
||||
"@vue/runtime-core": "3.2.47",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.5.1",
|
||||
"eslint": "8.33.0",
|
||||
"cypress": "12.6.0",
|
||||
"eslint": "8.34.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"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-tsc": "1.0.24"
|
||||
"vue-tsc": "1.1.4"
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const uiWindow = shallowRef<InstanceType<typeof MkWindow>>();
|
||||
const comment = ref(props.initialComment || '');
|
||||
const comment = ref(props.initialComment ?? '');
|
||||
|
||||
function send() {
|
||||
os.apiWithDialog('users/report-abuse', {
|
||||
|
@ -209,7 +209,7 @@ function exec() {
|
||||
}
|
||||
} else if (props.type === 'hashtag') {
|
||||
if (!props.q || props.q === '') {
|
||||
hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') || '[]');
|
||||
hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]');
|
||||
fetching.value = false;
|
||||
} else {
|
||||
const cacheKey = `autocomplete:hashtag:${props.q}`;
|
||||
|
@ -69,7 +69,7 @@ const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown
|
||||
if (loaded) {
|
||||
available.value = true;
|
||||
} 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,
|
||||
id: scriptId.value,
|
||||
src: src.value,
|
||||
|
@ -22,9 +22,6 @@ import * as game from '@/scripts/clicker-game';
|
||||
import number from '@/filters/number';
|
||||
import { claimAchievement } from '@/scripts/achievements';
|
||||
|
||||
defineProps<{
|
||||
}>();
|
||||
|
||||
const saveData = game.saveData;
|
||||
const cookies = computed(() => saveData.value?.cookies);
|
||||
let cps = $ref(0);
|
||||
|
@ -7,7 +7,6 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { length } from 'stringz';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { concat } from '@/scripts/array';
|
||||
import { i18n } from '@/i18n';
|
||||
@ -23,7 +22,7 @@ const emit = defineEmits<{
|
||||
|
||||
const label = computed(() => {
|
||||
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.poll != null ? [i18n.ts.poll] : [],
|
||||
] as string[][]).join(' / ');
|
||||
|
@ -14,8 +14,12 @@
|
||||
</div>
|
||||
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
|
||||
<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 #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>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
<template v-if="select.items">
|
||||
@ -28,7 +32,7 @@
|
||||
</template>
|
||||
</MkSelect>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="actions" :class="$style.buttons">
|
||||
@ -47,9 +51,12 @@ import MkSelect from '@/components/MkSelect.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
type Input = {
|
||||
type: HTMLInputElement['type'];
|
||||
type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
|
||||
placeholder?: string | null;
|
||||
default: any | null;
|
||||
autocomplete?: string;
|
||||
default: string | number | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
type Select = {
|
||||
@ -98,8 +105,28 @@ const emit = defineEmits<{
|
||||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const inputValue = ref(props.input?.default || null);
|
||||
const selectedValue = ref(props.select?.default || null);
|
||||
const inputValue = ref<string | number | null>(props.input?.default ?? null);
|
||||
const selectedValue = ref(props.select?.default ?? null);
|
||||
|
||||
let disabledReason = $ref<null | 'charactersExceeded' | 'charactersBelow'>(null);
|
||||
const okButtonDisabled = $computed<boolean>(() => {
|
||||
if (props.input) {
|
||||
if (props.input.minLength) {
|
||||
if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) {
|
||||
disabledReason = 'charactersBelow';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (props.input.maxLength) {
|
||||
if (inputValue.value && (inputValue.value as string).length > props.input.maxLength) {
|
||||
disabledReason = 'charactersExceeded';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
function done(canceled: boolean, result?) {
|
||||
emit('done', { canceled, result });
|
||||
|
@ -1,13 +1,20 @@
|
||||
<template>
|
||||
<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]">
|
||||
<div :class="$style.header" class="_button" @click="toggle">
|
||||
<span :class="$style.headerIcon"><slot name="icon"></slot></span>
|
||||
<span :class="$style.headerText"><slot name="label"></slot></span>
|
||||
<span :class="$style.headerRight">
|
||||
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
|
||||
<div :class="$style.headerText">
|
||||
<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>
|
||||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-else class="ti ti-chevron-down icon"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
|
||||
<Transition
|
||||
@ -139,6 +146,17 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.headerUpper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headerLower {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: .85em;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.headerIcon {
|
||||
margin-right: 0.75em;
|
||||
flex-shrink: 0;
|
||||
@ -161,6 +179,15 @@ onMounted(() => {
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.headerTextMain {
|
||||
|
||||
}
|
||||
|
||||
.headerTextSub {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
margin-left: auto;
|
||||
opacity: 0.7;
|
||||
|
@ -23,7 +23,7 @@
|
||||
@input="onInput"
|
||||
>
|
||||
<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>
|
||||
<div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div>
|
||||
</div>
|
||||
@ -41,7 +41,7 @@ import { useInterval } from '@/scripts/use-interval';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | number;
|
||||
modelValue: string | number | null;
|
||||
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
@ -49,7 +49,7 @@ const props = defineProps<{
|
||||
pattern?: string;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
autocomplete?: boolean;
|
||||
autocomplete?: string;
|
||||
spellcheck?: boolean;
|
||||
step?: any;
|
||||
datalist?: string[];
|
||||
|
@ -45,8 +45,8 @@ onMounted(() => {
|
||||
src: media.url,
|
||||
w: media.properties.width,
|
||||
h: media.properties.height,
|
||||
alt: media.comment || media.name,
|
||||
comment: media.comment || media.name,
|
||||
alt: media.comment ?? media.name,
|
||||
comment: media.comment ?? media.name,
|
||||
};
|
||||
if (media.properties.orientation != null && media.properties.orientation >= 5) {
|
||||
[item.w, item.h] = [item.h, item.w];
|
||||
@ -90,8 +90,8 @@ onMounted(() => {
|
||||
[itemData.w, itemData.h] = [itemData.h, itemData.w];
|
||||
}
|
||||
itemData.msrc = file.thumbnailUrl;
|
||||
itemData.alt = file.comment || file.name;
|
||||
itemData.comment = file.comment || file.name;
|
||||
itemData.alt = file.comment ?? file.name;
|
||||
itemData.comment = file.comment ?? file.name;
|
||||
itemData.thumbCropped = true;
|
||||
});
|
||||
|
||||
|
@ -199,6 +199,8 @@ const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(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.length > 500) ||
|
||||
(appearNote.files.length >= 5) ||
|
||||
|
@ -54,7 +54,7 @@ const props = withDefaults(defineProps<{
|
||||
showGlobalToggle: true,
|
||||
});
|
||||
|
||||
let includingTypes = $computed(() => props.includingTypes || []);
|
||||
let includingTypes = $computed(() => props.includingTypes ?? []);
|
||||
|
||||
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
|
@ -18,7 +18,7 @@
|
||||
</template>
|
||||
|
||||
<div :class="$style.root" :style="{ background: pageMetadata?.value?.bg }" style="container-type: inline-size;">
|
||||
<RouterView :router="router"/>
|
||||
<RouterView :key="reloadCount" :router="router"/>
|
||||
</div>
|
||||
</MkWindow>
|
||||
</template>
|
||||
@ -67,6 +67,10 @@ const buttonsLeft = $computed(() => {
|
||||
});
|
||||
const buttonsRight = $computed(() => {
|
||||
const buttons = [{
|
||||
icon: 'ti ti-reload',
|
||||
title: i18n.ts.reload,
|
||||
onClick: reload,
|
||||
}, {
|
||||
icon: 'ti ti-player-eject',
|
||||
title: i18n.ts.showInPage,
|
||||
onClick: expand,
|
||||
@ -74,6 +78,7 @@ const buttonsRight = $computed(() => {
|
||||
|
||||
return buttons;
|
||||
});
|
||||
let reloadCount = $ref(0);
|
||||
|
||||
router.addListener('push', ctx => {
|
||||
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);
|
||||
}
|
||||
|
||||
function reload() {
|
||||
reloadCount++;
|
||||
}
|
||||
|
||||
function close() {
|
||||
windowEl.close();
|
||||
}
|
||||
|
@ -104,7 +104,7 @@ const {
|
||||
enableInfiniteScroll,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
const contentEl = $computed(() => props.pagination.pageEl || rootEl);
|
||||
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
|
||||
const scrollableElement = $computed(() => getScrollContainer(contentEl));
|
||||
|
||||
// 先頭が表示されているかどうかを検出
|
||||
|
@ -75,7 +75,6 @@ import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as misskey from 'misskey-js';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import { length } from 'stringz';
|
||||
import { toASCII } from 'punycode/';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
@ -158,7 +157,7 @@ let autocomplete = $ref(null);
|
||||
let draghover = $ref(false);
|
||||
let quoteId = $ref(null);
|
||||
let hasNotSpecifiedMentions = $ref(false);
|
||||
let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') || '[]'));
|
||||
let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'));
|
||||
let imeText = $ref('');
|
||||
|
||||
const draftKey = $computed((): string => {
|
||||
@ -204,7 +203,7 @@ const submitText = $computed((): string => {
|
||||
});
|
||||
|
||||
const textLength = $computed((): number => {
|
||||
return length((text + imeText).trim());
|
||||
return (text + imeText).trim().length;
|
||||
});
|
||||
|
||||
const maxTextLength = $computed((): number => {
|
||||
@ -543,7 +542,7 @@ function onDrop(ev): void {
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
const draftData = JSON.parse(miLocalStorage.getItem('drafts') || '{}');
|
||||
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
|
||||
|
||||
draftData[draftKey] = {
|
||||
updatedAt: new Date(),
|
||||
@ -652,7 +651,7 @@ async function post(ev?: MouseEvent) {
|
||||
emit('posted');
|
||||
if (postData.text && postData.text !== '') {
|
||||
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))));
|
||||
}
|
||||
posting = false;
|
||||
@ -756,7 +755,7 @@ onMounted(() => {
|
||||
nextTick(() => {
|
||||
// 書きかけの投稿を復元
|
||||
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) {
|
||||
text = draft.data.text;
|
||||
useCw = draft.data.useCw;
|
||||
|
@ -1,10 +1,15 @@
|
||||
<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">
|
||||
<span :class="$style.icon">
|
||||
<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 class="ti ti-user" style="opacity: 0.7;"></i>
|
||||
<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-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>
|
||||
</template>
|
||||
</span>
|
||||
<span :class="$style.name">{{ role.name }}</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<{
|
||||
role: any;
|
||||
forModeration: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@ -38,6 +44,11 @@ const props = defineProps<{
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
height: 1.3em;
|
||||
vertical-align: -20%;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ import { useInterval } from '@/scripts/use-interval';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
modelValue: string | null;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
@ -48,7 +48,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'change', _ev: KeyboardEvent): void;
|
||||
(ev: 'update:modelValue', value: string): void;
|
||||
(ev: 'update:modelValue', value: string | null): void;
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
|
@ -10,7 +10,7 @@
|
||||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
</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 #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
|
||||
</MkInput>
|
||||
@ -28,11 +28,11 @@
|
||||
</div>
|
||||
<div class="twofa-group totp-group">
|
||||
<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 #prefix><i class="ti ti-lock"></i></template>
|
||||
</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 #prefix><i class="ti ti-123"></i></template>
|
||||
</MkInput>
|
||||
|
@ -1,10 +1,10 @@
|
||||
<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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, provide, onUnmounted } from 'vue';
|
||||
import XNotes from '@/components/MkNotes.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import { stream } from '@/stream';
|
||||
import * as sound from '@/scripts/sound';
|
||||
import { $i } from '@/account';
|
||||
@ -24,7 +24,7 @@ const emit = defineEmits<{
|
||||
|
||||
provide('inChannel', computed(() => props.src === 'channel'));
|
||||
|
||||
const tlComponent: InstanceType<typeof XNotes> = $ref();
|
||||
const tlComponent: InstanceType<typeof MkNotes> = $ref();
|
||||
|
||||
const prepend = note => {
|
||||
tlComponent.pagingComponent?.prepend(note);
|
||||
|
@ -7,9 +7,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: users }">
|
||||
<template #default="{ items }">
|
||||
<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>
|
||||
</template>
|
||||
</MkPagination>
|
||||
@ -20,10 +20,13 @@ import MkUserInfo from '@/components/MkUserInfo.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
noGap?: boolean;
|
||||
}>();
|
||||
extractor?: (item: any) => any;
|
||||
}>(), {
|
||||
extractor: (item) => item,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -16,7 +16,7 @@
|
||||
<template #label>{{ i18n.ts.username }}</template>
|
||||
<template #prefix>@</template>
|
||||
</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 #prefix>@</template>
|
||||
</MkInput>
|
||||
@ -61,6 +61,7 @@ import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
import { hostname } from '@/config';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', selected: misskey.entities.UserDetailed): void;
|
||||
@ -115,7 +116,7 @@ onMounted(() => {
|
||||
os.api('users/show', {
|
||||
userIds: defaultStore.state.recentlyUsedUsers,
|
||||
}).then(users => {
|
||||
if (props.includeSelf) {
|
||||
if (props.includeSelf && users.find(x => $i ? x.id === $i.id : true) == null) {
|
||||
recentUsers = [$i, ...users];
|
||||
} else {
|
||||
recentUsers = users;
|
||||
|
@ -24,7 +24,7 @@ const rawUrl = computed(() => {
|
||||
return props.url;
|
||||
}
|
||||
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`;
|
||||
});
|
||||
@ -32,7 +32,7 @@ const rawUrl = computed(() => {
|
||||
const url = computed(() =>
|
||||
defaultStore.reactiveState.disableShowingAnimatedImages.value && rawUrl.value
|
||||
? getStaticImageUrl(rawUrl.value)
|
||||
: rawUrl.value
|
||||
: rawUrl.value,
|
||||
);
|
||||
|
||||
const alt = computed(() => `:${customEmojiName.value}:`);
|
||||
|
@ -1,23 +1,33 @@
|
||||
<template>
|
||||
<div ref="el" :class="$style.tabs" @wheel="onTabWheel">
|
||||
<div :class="$style.tabsInner">
|
||||
<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 }]"
|
||||
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)">
|
||||
<div :class="$style.tabInner">
|
||||
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
|
||||
<div v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)"
|
||||
:class="$style.tabTitle">{{ 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>
|
||||
</Transition>
|
||||
<div ref="el" :class="$style.tabs" @wheel="onTabWheel">
|
||||
<div :class="$style.tabsInner">
|
||||
<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 }]"
|
||||
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"
|
||||
>
|
||||
<div :class="$style.tabInner">
|
||||
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
|
||||
<div
|
||||
v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)"
|
||||
:class="$style.tabTitle"
|
||||
>
|
||||
{{ t.title }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="tabHighlightEl"
|
||||
:class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"></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>
|
||||
</Transition>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="tabHighlightEl"
|
||||
:class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@ -206,8 +216,8 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tabIcon+.tabTitle {
|
||||
padding-left: 8px;
|
||||
.tabIcon + .tabTitle {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.tabTitle {
|
||||
|
@ -2,9 +2,9 @@
|
||||
<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }">
|
||||
<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">
|
||||
<MkAvatar :class="$style.avatar" :user="$i" />
|
||||
<MkAvatar :class="$style.avatar" :user="$i"/>
|
||||
</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">
|
||||
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
|
||||
@ -39,12 +39,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref, inject } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import XTabs, { Tab } from './MkPageHeader.tabs.vue';
|
||||
import { scrollToTop } from '@/scripts/scroll';
|
||||
import { globalEvents } from '@/events';
|
||||
import { injectPageMetadata } from '@/scripts/page-metadata';
|
||||
import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import XTabs, { Tab } from './MkPageHeader.tabs.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tabs?: Tab[];
|
||||
@ -100,7 +100,7 @@ function onTabClick(): void {
|
||||
}
|
||||
|
||||
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);
|
||||
tinyBg.setAlpha(0.85);
|
||||
bg.value = tinyBg.toRgbString();
|
||||
|
@ -286,7 +286,7 @@ export default defineComponent({
|
||||
case 'hashtag': {
|
||||
return [h(MkA, {
|
||||
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);',
|
||||
}, `#${token.props.hashtag}`)];
|
||||
}
|
||||
|
@ -246,7 +246,10 @@ export function inputText(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: string | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: string;
|
||||
}> {
|
||||
@ -257,7 +260,10 @@ export function inputText(props: {
|
||||
input: {
|
||||
type: props.type,
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
minLength: props.minLength,
|
||||
maxLength: props.maxLength,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
@ -271,6 +277,7 @@ export function inputNumber(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: number | null;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: number;
|
||||
@ -282,6 +289,7 @@ export function inputNumber(props: {
|
||||
input: {
|
||||
type: 'number',
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
},
|
||||
}, {
|
||||
|
@ -84,6 +84,10 @@
|
||||
</div>
|
||||
<p>{{ i18n.ts._aboutMisskey.morePatrons }}</p>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
<template #label>Credits</template>
|
||||
<p>Misskeyで使われる画像の一部は、許可を得て「あの子がこっちを見てるメーカー」で作成したものが含まれます。</p>
|
||||
</FormSection>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
@ -114,6 +118,9 @@ const patronsWithIcon = [{
|
||||
}, {
|
||||
name: 'narazaka',
|
||||
icon: 'https://misskey-hub.net/patrons/e3affff31ffb4877b1196c7360abc3e5.jpg',
|
||||
}, {
|
||||
name: 'ひとぅ',
|
||||
icon: 'https://misskey-hub.net/patrons/8cc0d0a0a6d84c88bca1aedabf6ed5ab.jpg',
|
||||
}];
|
||||
|
||||
const patrons = [
|
||||
|
@ -113,7 +113,7 @@ function onTabClick(tab: Tab, ev: MouseEvent): void {
|
||||
}
|
||||
|
||||
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);
|
||||
tinyBg.setAlpha(0.85);
|
||||
bg.value = tinyBg.toRgbString();
|
||||
|
@ -44,6 +44,9 @@
|
||||
<MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<MkButton class="button" @click="more()">
|
||||
<i class="ti ti-reload"></i>{{ i18n.ts.more }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</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(() => [{
|
||||
asFullButton: true,
|
||||
icon: 'ti ti-plus',
|
||||
|
@ -16,16 +16,29 @@
|
||||
<MkFolder v-if="role.target === 'manual'" default-open>
|
||||
<template #icon><i class="ti ti-users"></i></template>
|
||||
<template #label>{{ i18n.ts.users }}</template>
|
||||
<template #suffix>{{ role.users.length }}</template>
|
||||
<template #suffix>{{ role.usersCount }}</template>
|
||||
<div class="_gaps">
|
||||
<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">
|
||||
<MkA :class="$style.user" :to="`/user-info/${user.id}`">
|
||||
<MkUserCardMini :user="user"/>
|
||||
</MkA>
|
||||
<button class="_button" :class="$style.unassign" @click="unassign(user, $event)"><i class="ti ti-x"></i></button>
|
||||
</div>
|
||||
<MkPagination :pagination="usersPagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<div>{{ i18n.ts.noUsers }}</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>
|
||||
</MkFolder>
|
||||
<MkInfo v-else>{{ i18n.ts._role.isConditionalRole }}</MkInfo>
|
||||
@ -47,6 +60,7 @@ import { useRouter } from '@/router';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@ -54,6 +68,14 @@ const props = defineProps<{
|
||||
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', {
|
||||
roleId: props.id,
|
||||
}));
|
||||
|
@ -133,7 +133,7 @@
|
||||
</div>
|
||||
</MkFolder>
|
||||
<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>
|
||||
</MkSpacer>
|
||||
|
@ -4,7 +4,7 @@
|
||||
<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 class="tl blur">
|
||||
<XTimeline
|
||||
<MkTimeline
|
||||
ref="tlEl" :key="antennaId"
|
||||
class="tl"
|
||||
src="antenna"
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import XTimeline from '@/components/MkTimeline.vue';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import { scroll } from '@/scripts/scroll';
|
||||
import * as os from '@/os';
|
||||
import { useRouter } from '@/router';
|
||||
@ -35,7 +35,7 @@ const props = defineProps<{
|
||||
let antenna = $ref(null);
|
||||
let queue = $ref(0);
|
||||
let rootEl = $shallowRef<HTMLElement>();
|
||||
let tlEl = $shallowRef<InstanceType<typeof XTimeline>>();
|
||||
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||
const keymap = $computed(() => ({
|
||||
't': focus,
|
||||
}));
|
||||
|
@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs" /></template>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="500">
|
||||
<div v-if="state == 'fetch-session-error'">
|
||||
<p>{{ i18n.ts.somethingHappened }}</p>
|
||||
</div>
|
||||
<div v-else-if="$i && !session">
|
||||
<MkLoading />
|
||||
<MkLoading/>
|
||||
</div>
|
||||
<div v-else-if="$i && session">
|
||||
<XForm
|
||||
@ -21,15 +21,16 @@
|
||||
</div>
|
||||
<div v-if="state == 'accepted' && session">
|
||||
<h1>{{ session.app.isAuthorized ? $t('already-authorized') : i18n.ts.allowed }}</h1>
|
||||
<p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }}
|
||||
<MkEllipsis />
|
||||
<p v-if="session.app.callbackUrl">
|
||||
{{ i18n.ts._auth.callback }}
|
||||
<MkEllipsis/>
|
||||
</p>
|
||||
<p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
|
||||
<MkSignin @login="onLogin" />
|
||||
<MkSignin @login="onLogin"/>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
@ -37,12 +38,12 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { AuthSession } from 'misskey-js/built/entities';
|
||||
import XForm from './auth.form.vue';
|
||||
import MkSignin from '@/components/MkSignin.vue';
|
||||
import * as os from '@/os';
|
||||
import { $i, login } from '@/account';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { AuthSession } from 'misskey-js/built/entities';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
@ -82,7 +83,7 @@ onMounted(async () => {
|
||||
} else {
|
||||
state = 'waiting';
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
state = 'fetch-session-error';
|
||||
}
|
||||
});
|
||||
|
@ -25,7 +25,7 @@
|
||||
|
||||
<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>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
@ -34,7 +34,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from '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 * as os from '@/os';
|
||||
import { useRouter } from '@/router';
|
||||
|
@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<XNotes :pagination="pagination" :detail="true"/>
|
||||
<MkNotes :pagination="pagination" :detail="true"/>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
@ -21,7 +21,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch, provide } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import XNotes from '@/components/MkNotes.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
|
@ -4,13 +4,13 @@
|
||||
<option value="notes">{{ i18n.ts.notes }}</option>
|
||||
<option value="polls">{{ i18n.ts.poll }}</option>
|
||||
</MkTab>
|
||||
<XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
|
||||
<XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
|
||||
<MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
|
||||
<MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
|
||||
</MkSpacer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import XNotes from '@/components/MkNotes.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import MkTab from '@/components/MkTab.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
|
22
packages/frontend/src/pages/explore.roles.vue
Normal file
22
packages/frontend/src/pages/explore.roles.vue
Normal 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>
|
||||
|
@ -8,19 +8,19 @@
|
||||
<template v-if="tag == null">
|
||||
<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>
|
||||
<XUserList :pagination="pinnedUsers"/>
|
||||
<MkUserList :pagination="pinnedUsers"/>
|
||||
</MkFoldableSection>
|
||||
<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>
|
||||
<XUserList :pagination="popularUsers"/>
|
||||
<MkUserList :pagination="popularUsers"/>
|
||||
</MkFoldableSection>
|
||||
<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>
|
||||
<XUserList :pagination="recentlyUpdatedUsers"/>
|
||||
<MkUserList :pagination="recentlyUpdatedUsers"/>
|
||||
</MkFoldableSection>
|
||||
<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>
|
||||
<XUserList :pagination="recentlyRegisteredUsers"/>
|
||||
<MkUserList :pagination="recentlyRegisteredUsers"/>
|
||||
</MkFoldableSection>
|
||||
</template>
|
||||
</div>
|
||||
@ -29,28 +29,28 @@
|
||||
<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
|
||||
|
||||
<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 tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ 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="`/user-tags/${tag.tag}`">{{ tag.tag }}</MkA>
|
||||
</div>
|
||||
</MkFoldableSection>
|
||||
|
||||
<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>
|
||||
<XUserList :pagination="tagUsers"/>
|
||||
<MkUserList :pagination="tagUsers"/>
|
||||
</MkFoldableSection>
|
||||
|
||||
<template v-if="tag == null">
|
||||
<MkFoldableSection class="_margin">
|
||||
<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 class="_margin">
|
||||
<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 class="_margin">
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
@ -59,7 +59,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch } from 'vue';
|
||||
import XUserList from '@/components/MkUserList.vue';
|
||||
import MkUserList from '@/components/MkUserList.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import MkTab from '@/components/MkTab.vue';
|
||||
import * as os from '@/os';
|
||||
|
@ -8,6 +8,9 @@
|
||||
<div v-else-if="tab === 'users'">
|
||||
<XUsers/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'roles'">
|
||||
<XRoles/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'search'">
|
||||
<MkSpacer :content-max="1200">
|
||||
<div>
|
||||
@ -22,7 +25,7 @@
|
||||
</MkRadios>
|
||||
</div>
|
||||
|
||||
<XUserList v-if="searchQuery" ref="searchEl" class="_margin" :pagination="searchPagination"/>
|
||||
<MkUserList v-if="searchQuery" ref="searchEl" class="_margin" :pagination="searchPagination"/>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</div>
|
||||
@ -33,12 +36,13 @@
|
||||
import { computed, watch } from 'vue';
|
||||
import XFeatured from './explore.featured.vue';
|
||||
import XUsers from './explore.users.vue';
|
||||
import XRoles from './explore.roles.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { i18n } from '@/i18n';
|
||||
import XUserList from '@/components/MkUserList.vue';
|
||||
import MkUserList from '@/components/MkUserList.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tag?: string;
|
||||
@ -75,8 +79,13 @@ const headerTabs = $computed(() => [{
|
||||
key: 'users',
|
||||
icon: 'ti ti-users',
|
||||
title: i18n.ts.users,
|
||||
}, {
|
||||
key: 'roles',
|
||||
icon: 'ti ti-badges',
|
||||
title: i18n.ts.roles,
|
||||
}, {
|
||||
key: 'search',
|
||||
icon: 'ti ti-search',
|
||||
title: i18n.ts.search,
|
||||
}]);
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
|
||||
<div v-if="note" class="note">
|
||||
<div v-if="showNext" class="_margin">
|
||||
<XNotes class="" :pagination="nextPagination" :no-gap="true"/>
|
||||
<MkNotes class="" :pagination="nextPagination" :no-gap="true"/>
|
||||
</div>
|
||||
|
||||
<div class="main _margin">
|
||||
@ -29,7 +29,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="showPrev" class="_margin">
|
||||
<XNotes class="" :pagination="prevPagination" :no-gap="true"/>
|
||||
<MkNotes class="" :pagination="prevPagination" :no-gap="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<MkError v-else-if="error" @retry="fetchNote()"/>
|
||||
@ -44,7 +44,7 @@
|
||||
import { computed, watch } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
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 MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os';
|
||||
|
@ -6,10 +6,10 @@
|
||||
<XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'mentions'">
|
||||
<XNotes :pagination="mentionsPagination"/>
|
||||
<MkNotes :pagination="mentionsPagination"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'directNotes'">
|
||||
<XNotes :pagination="directNotesPagination"/>
|
||||
<MkNotes :pagination="directNotesPagination"/>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
@ -19,7 +19,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { notificationTypes } from 'misskey-js';
|
||||
import XNotifications from '@/components/MkNotifications.vue';
|
||||
import XNotes from '@/components/MkNotes.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
|
47
packages/frontend/src/pages/role.vue
Normal file
47
packages/frontend/src/pages/role.vue
Normal 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>
|
||||
|
@ -2,14 +2,14 @@
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="800">
|
||||
<XNotes ref="notes" :pagination="pagination"/>
|
||||
<MkNotes ref="notes" :pagination="pagination"/>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import XNotes from '@/components/MkNotes.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import * as os from '@/os';
|
||||
|
82
packages/frontend/src/pages/settings/2fa.qrdialog.vue
Normal file
82
packages/frontend/src/pages/settings/2fa.qrdialog.vue
Normal 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>
|
@ -1,216 +1,258 @@
|
||||
<template>
|
||||
<div>
|
||||
<MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton>
|
||||
<template v-if="$i.twoFactorEnabled">
|
||||
<p>{{ i18n.ts._2fa.alreadyRegistered }}</p>
|
||||
<MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton>
|
||||
<FormSection :first="first">
|
||||
<template #label>{{ i18n.ts['2fa'] }}</template>
|
||||
|
||||
<template v-if="supportsCredentials">
|
||||
<hr class="totp-method-sep">
|
||||
|
||||
<h2 class="heading">{{ i18n.ts.securityKey }}</h2>
|
||||
<p>{{ i18n.ts._2fa.securityKeyInfo }}</p>
|
||||
<div class="key-list">
|
||||
<div v-for="key in $i.securityKeysList" class="key">
|
||||
<h3>{{ key.name }}</h3>
|
||||
<div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div>
|
||||
<MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton>
|
||||
</div>
|
||||
<div v-if="$i" class="_gaps_s">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-shield-lock"></i></template>
|
||||
<template #label>{{ i18n.ts.totp }}</template>
|
||||
<template #caption>{{ i18n.ts.totpDescription }}</template>
|
||||
<div v-if="$i.twoFactorEnabled" class="_gaps_s">
|
||||
<div v-text="i18n.ts._2fa.alreadyRegistered"/>
|
||||
<template v-if="$i.securityKeysList.length > 0">
|
||||
<MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton>
|
||||
<MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
|
||||
</template>
|
||||
<MkButton v-else @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
|
||||
</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>
|
||||
<MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton>
|
||||
<MkFolder>
|
||||
<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">
|
||||
<li v-if="registration.stage >= 0">
|
||||
{{ i18n.ts.tapSecurityKey }}
|
||||
<MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/>
|
||||
</li>
|
||||
<li v-if="registration.stage >= 1">
|
||||
<MkForm :disabled="registration.stage != 1 || registration.saving">
|
||||
<MkInput v-model="keyName" :max="30">
|
||||
<template #label>{{ i18n.ts.securityKeyName }}</template>
|
||||
</MkInput>
|
||||
<MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton>
|
||||
<MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/>
|
||||
</MkForm>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</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>
|
||||
<MkInfo v-if="!supportsCredentials" warn>
|
||||
{{ i18n.ts._2fa.securityKeyNotSupported }}
|
||||
</MkInfo>
|
||||
|
||||
<MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn>
|
||||
{{ i18n.ts._2fa.registerTOTPBeforeKey }}
|
||||
</MkInfo>
|
||||
|
||||
<template v-else>
|
||||
<MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton>
|
||||
<MkFolder v-for="key in $i.securityKeysList" :key="key.id">
|
||||
<template #label>{{ key.name }}</template>
|
||||
<template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template>
|
||||
<div class="_buttons">
|
||||
<MkButton @click="renameKey(key)"><i class="ti ti-forms"></i> {{ i18n.ts.rename }}</MkButton>
|
||||
<MkButton danger @click="unregisterKey(key)"><i class="ti ti-trash"></i> {{ i18n.ts.unregister }}</MkButton>
|
||||
</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>
|
||||
</div>
|
||||
</FormSection>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, defineAsyncComponent } from 'vue';
|
||||
import { hostname } from '@/config';
|
||||
import { byteify, hexify, stringify } from '@/scripts/2fa';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkInput from '@/components/MkInput.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 { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
// メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要
|
||||
|
||||
withDefaults(defineProps<{
|
||||
first?: boolean;
|
||||
}>(), {
|
||||
first: false,
|
||||
});
|
||||
|
||||
const twoFactorData = ref<any>(null);
|
||||
const supportsCredentials = ref(!!navigator.credentials);
|
||||
const usePasswordLessLogin = ref($i!.usePasswordLessLogin);
|
||||
const registration = ref<any>(null);
|
||||
const keyName = ref('');
|
||||
const token = ref(null);
|
||||
const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
|
||||
|
||||
function register() {
|
||||
async function registerTOTP() {
|
||||
const password = await os.inputText({
|
||||
title: i18n.ts._2fa.registerTOTP,
|
||||
text: i18n.ts._2fa.passwordToTOTP,
|
||||
type: 'password',
|
||||
autocomplete: 'current-password',
|
||||
});
|
||||
if (password.canceled) return;
|
||||
|
||||
const twoFactorData = await os.apiWithDialog('i/2fa/register', {
|
||||
password: password.result,
|
||||
});
|
||||
|
||||
const qrdialog = await new Promise<boolean>(res => {
|
||||
os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
|
||||
twoFactorData,
|
||||
}, {
|
||||
'ok': () => res(true),
|
||||
'cancel': () => res(false),
|
||||
}, 'closed');
|
||||
});
|
||||
if (!qrdialog) return;
|
||||
|
||||
const token = await os.inputNumber({
|
||||
title: i18n.ts._2fa.step3Title,
|
||||
text: i18n.ts._2fa.step3,
|
||||
autocomplete: 'one-time-code',
|
||||
});
|
||||
if (token.canceled) return;
|
||||
|
||||
await os.apiWithDialog('i/2fa/done', {
|
||||
token: token.result.toString(),
|
||||
});
|
||||
|
||||
await os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts._2fa.step4,
|
||||
});
|
||||
}
|
||||
|
||||
function unregisterTOTP() {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: 'password',
|
||||
autocomplete: 'current-password',
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api('i/2fa/register', {
|
||||
os.apiWithDialog('i/2fa/unregister', {
|
||||
password: password,
|
||||
}).then(data => {
|
||||
twoFactorData.value = data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unregister() {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: 'password',
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api('i/2fa/unregister', {
|
||||
password: password,
|
||||
}).then(() => {
|
||||
usePasswordLessLogin.value = false;
|
||||
updatePasswordLessLogin();
|
||||
}).then(() => {
|
||||
os.success();
|
||||
$i!.twoFactorEnabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function submit() {
|
||||
os.api('i/2fa/done', {
|
||||
token: token.value,
|
||||
}).then(() => {
|
||||
os.success();
|
||||
$i!.twoFactorEnabled = true;
|
||||
}).catch(err => {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: err,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerKey() {
|
||||
registration.value.saving = true;
|
||||
os.api('i/2fa/key-done', {
|
||||
password: registration.value.password,
|
||||
name: keyName.value,
|
||||
challengeId: registration.value.challengeId,
|
||||
// we convert each 16 bits to a string to serialise
|
||||
clientDataJSON: stringify(registration.value.credential.response.clientDataJSON),
|
||||
attestationObject: hexify(registration.value.credential.response.attestationObject),
|
||||
}).then(key => {
|
||||
registration.value = null;
|
||||
key.lastUsed = new Date();
|
||||
os.success();
|
||||
});
|
||||
}
|
||||
|
||||
function unregisterKey(key) {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: 'password',
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
return os.api('i/2fa/remove-key', {
|
||||
password,
|
||||
credentialId: key.id,
|
||||
}).then(() => {
|
||||
usePasswordLessLogin.value = false;
|
||||
updatePasswordLessLogin();
|
||||
}).then(() => {
|
||||
os.success();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addSecurityKey() {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: 'password',
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api('i/2fa/register-key', {
|
||||
password,
|
||||
}).then(reg => {
|
||||
registration.value = {
|
||||
password,
|
||||
challengeId: reg!.challengeId,
|
||||
stage: 0,
|
||||
publicKeyOptions: {
|
||||
challenge: byteify(reg!.challenge, 'base64'),
|
||||
rp: {
|
||||
id: hostname,
|
||||
name: 'Misskey',
|
||||
},
|
||||
user: {
|
||||
id: byteify($i!.id, 'ascii'),
|
||||
name: $i!.username,
|
||||
displayName: $i!.name,
|
||||
},
|
||||
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
|
||||
timeout: 60000,
|
||||
attestation: 'direct',
|
||||
},
|
||||
saving: true,
|
||||
};
|
||||
return navigator.credentials.create({
|
||||
publicKey: registration.value.publicKeyOptions,
|
||||
}).catch(error => {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: error,
|
||||
});
|
||||
}).then(credential => {
|
||||
registration.value.credential = credential;
|
||||
registration.value.saving = false;
|
||||
registration.value.stage = 1;
|
||||
}).catch(err => {
|
||||
console.warn('Error while registering?', err);
|
||||
registration.value.error = err.message;
|
||||
registration.value.stage = -1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function updatePasswordLessLogin() {
|
||||
await os.api('i/2fa/password-less', {
|
||||
value: !!usePasswordLessLogin.value,
|
||||
function renewTOTP() {
|
||||
os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.ts._2fa.renewTOTP,
|
||||
text: i18n.ts._2fa.renewTOTPConfirm,
|
||||
okText: i18n.ts._2fa.renewTOTPOk,
|
||||
cancelText: i18n.ts._2fa.renewTOTPCancel,
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
registerTOTP();
|
||||
});
|
||||
}
|
||||
|
||||
async function unregisterKey(key) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.ts._2fa.removeKey,
|
||||
text: i18n.t('_2fa.removeKeyConfirm', { name: key.name }),
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
|
||||
const password = await os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: 'password',
|
||||
autocomplete: 'current-password',
|
||||
});
|
||||
if (password.canceled) return;
|
||||
|
||||
await os.apiWithDialog('i/2fa/remove-key', {
|
||||
password: password.result,
|
||||
credentialId: key.id,
|
||||
});
|
||||
os.success();
|
||||
}
|
||||
|
||||
async function renameKey(key) {
|
||||
const name = await os.inputText({
|
||||
title: i18n.ts.rename,
|
||||
default: key.name,
|
||||
type: 'text',
|
||||
minLength: 1,
|
||||
maxLength: 30,
|
||||
});
|
||||
if (name.canceled) return;
|
||||
|
||||
await os.apiWithDialog('i/2fa/update-key', {
|
||||
name: name.result,
|
||||
credentialId: key.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function addSecurityKey() {
|
||||
const password = await os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: 'password',
|
||||
autocomplete: 'current-password',
|
||||
});
|
||||
if (password.canceled) return;
|
||||
|
||||
const challenge: any = await os.apiWithDialog('i/2fa/register-key', {
|
||||
password: password.result,
|
||||
});
|
||||
|
||||
const name = await os.inputText({
|
||||
title: i18n.ts._2fa.registerSecurityKey,
|
||||
text: i18n.ts._2fa.securityKeyName,
|
||||
type: 'text',
|
||||
minLength: 1,
|
||||
maxLength: 30,
|
||||
});
|
||||
if (name.canceled) return;
|
||||
|
||||
const webAuthnCreation = navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: byteify(challenge.challenge, 'base64'),
|
||||
rp: {
|
||||
id: hostname,
|
||||
name: 'Misskey',
|
||||
},
|
||||
user: {
|
||||
id: byteify($i!.id, 'ascii'),
|
||||
name: $i!.username,
|
||||
displayName: $i!.name,
|
||||
},
|
||||
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
|
||||
timeout: 60000,
|
||||
attestation: 'direct',
|
||||
},
|
||||
}) as Promise<PublicKeyCredential & { response: AuthenticatorAttestationResponse; } | null>;
|
||||
|
||||
const credential = await os.promiseDialog(
|
||||
webAuthnCreation,
|
||||
null,
|
||||
() => {}, // ユーザーのキャンセルはrejectなのでエラーダイアログを出さない
|
||||
i18n.ts._2fa.tapSecurityKey,
|
||||
);
|
||||
if (!credential) return;
|
||||
|
||||
await os.apiWithDialog('i/2fa/key-done', {
|
||||
password: password.result,
|
||||
name: name.result,
|
||||
challengeId: challenge.challengeId,
|
||||
// we convert each 16 bits to a string to serialise
|
||||
clientDataJSON: stringify(credential.response.clientDataJSON),
|
||||
attestationObject: hexify(credential.response.attestationObject),
|
||||
});
|
||||
}
|
||||
|
||||
async function updatePasswordLessLogin(value: boolean) {
|
||||
await os.apiWithDialog('i/2fa/password-less', {
|
||||
value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
@ -124,11 +124,11 @@ function saveFields() {
|
||||
|
||||
function save() {
|
||||
os.apiWithDialog('i/update', {
|
||||
name: profile.name || null,
|
||||
description: profile.description || null,
|
||||
location: profile.location || null,
|
||||
birthday: profile.birthday || null,
|
||||
lang: profile.lang || null,
|
||||
name: profile.name ?? null,
|
||||
description: profile.description ?? null,
|
||||
location: profile.location ?? null,
|
||||
birthday: profile.birthday ?? null,
|
||||
lang: profile.lang ?? null,
|
||||
isBot: !!profile.isBot,
|
||||
isCat: !!profile.isCat,
|
||||
showTimelineReplies: !!profile.showTimelineReplies,
|
||||
|
@ -5,11 +5,8 @@
|
||||
<MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
|
||||
<X2fa/>
|
||||
</FormSection>
|
||||
|
||||
<X2fa/>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.signinHistory }}</template>
|
||||
<MkPagination :pagination="pagination" disable-auto-load>
|
||||
@ -56,18 +53,21 @@ async function change() {
|
||||
const { canceled: canceled1, result: currentPassword } = await os.inputText({
|
||||
title: i18n.ts.currentPassword,
|
||||
type: 'password',
|
||||
autocomplete: 'current-password',
|
||||
});
|
||||
if (canceled1) return;
|
||||
|
||||
const { canceled: canceled2, result: newPassword } = await os.inputText({
|
||||
title: i18n.ts.newPassword,
|
||||
type: 'password',
|
||||
autocomplete: 'new-password',
|
||||
});
|
||||
if (canceled2) return;
|
||||
|
||||
const { canceled: canceled3, result: newPassword2 } = await os.inputText({
|
||||
title: i18n.ts.newPasswordRetype,
|
||||
type: 'password',
|
||||
autocomplete: 'new-password',
|
||||
});
|
||||
if (canceled3) return;
|
||||
|
||||
@ -109,7 +109,7 @@ definePageMetadata({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.timnmucd {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 6px;
|
||||
|
@ -2,14 +2,14 @@
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="800">
|
||||
<XNotes class="" :pagination="pagination"/>
|
||||
<MkNotes class="" :pagination="pagination"/>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import XNotes from '@/components/MkNotes.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
|
||||
const props = defineProps<{
|
||||
|
@ -8,7 +8,7 @@
|
||||
|
||||
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||
<div :class="$style.tl" class="blur">
|
||||
<XTimeline
|
||||
<MkTimeline
|
||||
ref="tlComponent"
|
||||
:key="src"
|
||||
:src="src"
|
||||
@ -24,7 +24,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, computed, watch, provide } from '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 { scroll } from '@/scripts/scroll';
|
||||
import * as os from '@/os';
|
||||
@ -44,7 +44,7 @@ const keymap = {
|
||||
't': focus,
|
||||
};
|
||||
|
||||
const tlComponent = $shallowRef<InstanceType<typeof XTimeline>>();
|
||||
const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||
const rootEl = $shallowRef<HTMLElement>();
|
||||
|
||||
let queue = $ref(0);
|
||||
|
@ -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>
|
||||
|
||||
<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-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
|
||||
</div>
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div ref="rootEl" class="eqqrhokj">
|
||||
<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||
<div class="tl blur">
|
||||
<XTimeline
|
||||
<MkTimeline
|
||||
ref="tlEl" :key="listId"
|
||||
class="tl"
|
||||
src="list"
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import XTimeline from '@/components/MkTimeline.vue';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import { scroll } from '@/scripts/scroll';
|
||||
import * as os from '@/os';
|
||||
import { useRouter } from '@/router';
|
||||
@ -34,7 +34,7 @@ const props = defineProps<{
|
||||
|
||||
let list = $ref(null);
|
||||
let queue = $ref(0);
|
||||
let tlEl = $shallowRef<InstanceType<typeof XTimeline>>();
|
||||
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||
let rootEl = $shallowRef<HTMLElement>();
|
||||
|
||||
watch(() => props.listId, async () => {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user