diff --git a/CHANGELOG.md b/CHANGELOG.md index 7edf9c156d..ef02c8431b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +## 2025.4.1 + +### General +- Feat: bull-boardに代わるジョブキューの管理ツールが実装されました +- Enhance: チャットの新規メッセージをプッシュ通知するように + +### Client +- Feat: チャットウィジェットを追加 +- Feat: デッキにチャットカラムを追加 +- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように +- Enhance: コントロールパネルでジョブキューをクリアできるように +- Enhance: テーマでページヘッダーの色を変更できるように +- Enhance: デザインのブラッシュアップ +- Fix: ログアウトした際に処理が終了しない問題を修正 +- Fix: 自動バックアップが設定されている環境でログアウト直前に設定をバックアップするように +- Fix: フォルダを開いた状態でメニューからアップロードしてもルートフォルダにアップロードされる問題を修正 #15836 +- Fix: タイムラインのスクロール位置を記憶するように修正 +- Fix: ノートの直後のノートを表示する機能で表示が逆順になっていた問題を修正 #15841 +- Fix: アカウントの移行時にアンテナのフィルターのユーザが更新されない問題を修正 #15843 + +### Server +- Enhance: ジョブキューの成功/失敗したジョブも一定数・一定期間保存するようにし、後から問題を調査することを容易に +- Enhance: フォローしているユーザーならフォロワー限定投稿のノートでもアンテナで検知できるように + (Cherry-picked from https://github.com/yojo-art/cherrypick/pull/568 and https://github.com/team-shahu/misskey/pull/38) +- Enhance: ユーザーごとにノートの表示が高速化するように +- Fix: システムアカウントの名前がサーバー名と同期されない問題を修正 +- Fix: 大文字を含むユーザの URL で紹介された場合に 404 エラーを返す問題 #15813 +- Fix: リードレプリカ設定時にレコードの追加・更新・削除を伴うクエリを発行した際はmasterノードで実行されるように調整( #10897 ) + ## 2025.4.0 ### General @@ -30,7 +59,7 @@ - プラグイン、テーマ、クライアントに追加されたすべてのアカウント情報も含まれるようになりました - 自動で設定データをサーバーにバックアップできるように - 設定→設定のプロファイル→自動バックアップ で有効にできます - - 新しいデバイスからログインしたり、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能) + - ログインしたとき、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能) - 任意の設定項目をデバイス間で同期できるように - 設定項目の「...」メニュー→「デバイス間で同期」 - 同期をオンにした際にサーバーに保存された値とローカルの値が競合する場合はどちらを優先するか選択できます @@ -39,7 +68,7 @@ - アカウントごとに設定値が分離される設定とそうでないクライアント設定が混在していた(かつ分離するかどうかを設定不可だった)のを、基本的に一律でクライアント全体に適用されるようにし、個別でアカウントごとに異なる設定を行えるように - 設定項目の「...」メニュー→「アカウントで上書き」をオンにすることで、設定値をそのアカウントでだけ適用するようにできます - ログアウトすると設定データもブラウザから消去されるようになりプライバシーが向上しました - - 再度ログインすればサーバーのバックアップから設定データを復元可能です + - バックアップを有効にしている場合、ログインした後にバックアップから設定データを復元可能です - エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました - 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です - 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 8f83a33356..129c3d24a5 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -356,7 +356,7 @@ banner: "Bàner" displayOfSensitiveMedia: "Visualització de contingut sensible" whenServerDisconnected: "Quan es perdi la connexió al servidor" disconnectedFromServer: "Desconnectat pel servidor" -reload: "Actualitza" +reload: "Actualitzar" doNothing: "Ignora" reloadConfirm: "Vols recarregar?" watch: "Veure" @@ -708,7 +708,7 @@ notificationSetting: "Paràmetres de notificacions" notificationSettingDesc: "Selecciona els tipus de notificacions que es mostraran" useGlobalSetting: "Fer servir la configuració global" useGlobalSettingDesc: "Si s'activa, es farà servir la configuració de notificacions del teu comte. Si no s'activa es poden fer configuracions individuals." -other: "Altre" +other: "Altres" regenerateLoginToken: "Regenerar clau de seguretat d'inici de sessió" regenerateLoginTokenDescription: "Regenera la clau de seguretat que es fa servir internament durant l'inici de sessió. Normalment aquesta acció no és necessària. Si es regenera es tancarà la sessió a tots els dispositius amb una sessió activa." theKeywordWhenSearchingForCustomEmoji: "Cercar un emoji personalitzat " @@ -979,6 +979,7 @@ document: "Documentació" numberOfPageCache: "Nombre de pàgines a la memòria cau" numberOfPageCacheDescription: "Incrementant aquest nombre farà que millori l'experiència de l'usuari, però es farà servir més memòria al dispositiu de l'usuari." logoutConfirm: "Vols sortir?" +logoutWillClearClientData: "En tancar la sessió, la informació del client al navegador s'esborrarà. Per garantir que la informació de configuració es pugui restaurar en tornar a iniciar sessió activa la còpia de seguretat automàtica de la configuració." lastActiveDate: "Fet servir per última vegada" statusbar: "Barra d'estat" pleaseSelect: "Selecciona una opció" @@ -1334,14 +1335,14 @@ postForm: "Formulari de publicació" textCount: "Nombre de caràcters " information: "Informació" chat: "Xat" -migrateOldSettings: "Migració de la configuració antiga " +migrateOldSettings: "Migrar la configuració anterior" migrateOldSettings_description: "Normalment això es fa automàticament, però si la transició no es fa, el procés es pot iniciar manualment. S'esborrarà la configuració actual." compress: "Comprimir " right: "Dreta" bottom: "A baix " top: "A dalt " embed: "Incrustar" -settingsMigrating: "Estem fent la migració de la teva configuració. Si us plau espera un moment... (També pots fer la migració més tard i manualment anant a Preferències → Altres configuracions → Migrar configuració antiga)" +settingsMigrating: "Estem migrant la teva configuració. Si us plau espera un moment... (També pots fer la migració més tard, manualment, anant a Preferències → Altres → Migrar configuració antiga)" readonly: "Només lectura" goToDeck: "Tornar al tauler" _chat: @@ -1359,7 +1360,7 @@ _chat: noInvitations: "No tens cap invitació " history: "Historial de converses " noHistory: "No hi ha un registre previ" - noRooms: "No hi ha habitacions" + noRooms: "No hi ha cap sala" inviteUser: "Invitar usuaris" sentInvitations: "Enviar invitacions" join: "Afegir-se " @@ -1417,8 +1418,8 @@ _settings: makeEveryTextElementsSelectable_description: "L'activació pot reduir la usabilitat en determinades ocasions." useStickyIcons: "Utilitza icones fixes" showNavbarSubButtons: "Mostrar sub botons a la barra de navegació " - ifOn: "Quan s'encén " - ifOff: "Quan s'apaga " + ifOn: "Quan s'activa" + ifOff: "Quan es desactiva" enableSyncThemesBetweenDevices: "Sincronitzar els temes instal·lats entre dispositius" _chat: showSenderName: "Mostrar el nom del remitent" @@ -1604,7 +1605,7 @@ _accountMigration: moveTo: "Migrar aquest compte a un altre" moveToLabel: "Compte al qual es vol migrar:" moveCannotBeUndone: "Les migracions dels comptes no es poden desfer." - moveAccountDescription: "Això migrarà la teva compte a un altre diferent.\n ・Els seguidors d'aquest compte és passaran al compte nou de forma automàtica\n ・Es deixaran de seguir a tots els usuaris que es segueixen actualment en aquest compte\n ・No es poden crear notes noves, etc. en aquest compte\n\nSi bé la migració de seguidors es automàtica, has de preparar alguns pasos manualment per migrar la llista d'usuaris que segueixes. Per fer això has d'exportar els seguidors que després importaraes al compte nou mitjançant el menú de configuració. El mateix procediment s'ha de seguir per less teves llistes i els teus usuaris silenciats i bloquejats.\n\n(Aquesta explicació s'aplica a Misskey v13.12.0 i posteriors. Altres aplicacions, com Mastodon, poden funcionar diferent.)" + moveAccountDescription: "Això migrarà el teu compte a un altre diferent.\n ・Els seguidors d'aquest compte és passaran al compte nou de forma automàtica\n ・Es deixaran de seguir a tots els usuaris que es segueixen actualment en aquest compte\n ・No es poden crear notes noves, etc. en aquest compte\n\nSi bé la migració de seguidors es automàtica, has de preparar alguns pasos manualment per migrar la llista d'usuaris que segueixes. Per fer això has d'exportar els seguidors que després importaraes al compte nou mitjançant el menú de configuració. El mateix procediment s'ha de seguir per less teves llistes i els teus usuaris silenciats i bloquejats.\n\n(Aquesta explicació s'aplica a Misskey v13.12.0 i posteriors. Altres aplicacions, com Mastodon, poden funcionar diferent.)" moveAccountHowTo: "Per fer la migració, primer has de crear un àlies per aquest compte al compte al qual vols migrar.\nDesprés de crear l'àlies, introdueix el compte al qual vols migrar amb el format següent: @nomusuari@servidor.exemple.com" startMigration: "Migrar" migrationConfirm: "Vols migrar aquest compte a {account}? Una vegada comenci la migració no es podrà parar O fer marxa enrere i no podràs tornar a fer servir aquest compte mai més." @@ -2368,6 +2369,7 @@ _widgets: chooseList: "Tria una llista" clicker: "Clicker" birthdayFollowings: "Usuaris que fan l'aniversari avui" + chat: "Xat" _cw: hide: "Amagar" show: "Carregar més" @@ -2634,6 +2636,7 @@ _deck: mentions: "Mencions" direct: "Publicacions directes" roleTimeline: "Línia de temps dels rols" + chat: "Xat" _dialog: charactersExceeded: "Has arribat al màxim de caràcters! Actualment és {current} de {max}" charactersBelow: "Ets per sota del mínim de caràcters! Actualment és {current} de {min}" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index b953a5717e..237603299c 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -2368,6 +2368,7 @@ _widgets: chooseList: "Liste auswählen" clicker: "Klickzähler" birthdayFollowings: "Nutzer, die heute Geburtstag haben" + chat: "Chat" _cw: hide: "Inhalt verbergen" show: "Inhalt anzeigen" @@ -2634,6 +2635,7 @@ _deck: mentions: "Erwähnungen" direct: "Direktnachrichten" roleTimeline: "Rollenchronik" + chat: "Chat" _dialog: charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" diff --git a/locales/en-US.yml b/locales/en-US.yml index 06a536bcf6..b695a9bc5c 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -2368,6 +2368,7 @@ _widgets: chooseList: "Select a list" clicker: "Clicker" birthdayFollowings: "Today's Birthdays" + chat: "Chat" _cw: hide: "Hide" show: "Show content" @@ -2634,6 +2635,7 @@ _deck: mentions: "Mentions" direct: "Direct notes" roleTimeline: "Role Timeline" + chat: "Chat" _dialog: charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." diff --git a/locales/es-ES.yml b/locales/es-ES.yml index f60e9beabe..713478b67e 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -301,6 +301,7 @@ uploadFromUrlMayTakeTime: "Subir el fichero puede tardar un tiempo." explore: "Explorar" messageRead: "Ya leído" noMoreHistory: "El historial se ha acabado" +startChat: "Nuevo Chat" nUsersRead: "Leído por {n} personas" agreeTo: "De acuerdo con {0}" agree: "De acuerdo." @@ -694,6 +695,7 @@ userSaysSomethingAbout: "{name} dijo algo sobre {word}" makeActive: "Activar" display: "Apariencia" copy: "Copiar" +copiedToClipboard: "Texto copiado al portapapeles" metrics: "Métricas" overview: "Resumen" logs: "Registros" @@ -1293,18 +1295,55 @@ passkeyVerificationFailed: "La verificación de la clave de acceso ha fallado." passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificación de la clave de acceso ha sido satisfactoria pero se ha deshabilitado el inicio de sesión sin contraseña." messageToFollower: "Mensaje a seguidores" target: "Para" +prohibitedWordsForNameOfUser: "Palabras prohibidas para nombres de usuario" +prohibitedWordsForNameOfUserDescription: "Si alguna de las cadenas de esta lista está incluida en el nombre del usuario, el nombre será denegado. Los usuarios con privilegios de moderador no se ven afectados por esta restricción." +yourNameContainsProhibitedWords: "Tu nombre contiene palabras prohibidas" +yourNameContainsProhibitedWordsDescription: "Si deseas usar este nombre, por favor contacta con tu administrador/a de tu servidor" +lockdown: "Bloqueo" +pleaseSelectAccount: "Seleccione una cuenta, por favor." +availableRoles: "Roles disponibles " +acknowledgeNotesAndEnable: "Activar después de comprender las precauciones" federationSpecified: "Este servidor opera en una federación de listas blancas. No puede interactuar con otros servidores que no sean los especificados por el administrador." federationDisabled: "La federación está desactivada en este servidor. No puede interactuar con usuarios de otros servidores" preferences: "Preferencias" postForm: "Formulario" information: "Información" +right: "Derecha" +bottom: "Abajo" +top: "Arriba" +embed: "Insertar" +settingsMigrating: "La configuración está siendo migrada, por favor espera un momento... (También puedes migrar manualmente más tarde yendo a Ajustes otros migrar configuración antigua" +readonly: "Solo Lectura" _chat: + noMessagesYet: "Aún no hay mensajes" + newMessage: "Mensajes nuevos" + individualChat: "Chat individual" + individualChat_description: "Mantén una conversación privada con otra persona." invitations: "Invitar" noHistory: "No hay datos en el historial" members: "Miembros" home: "Inicio" send: "Enviar" + chatNotAvailableInOtherAccount: "La función de chat está desactivada para el otro usuario." + cannotChatWithTheUser: "No se puede iniciar un chat con este usuario" + cannotChatWithTheUser_description: "El chat no está disponible o la otra parte no ha habilitado el chat." + chatWithThisUser: "Chatear" + thisUserAllowsChatOnlyFromFollowers: "Este usuario sólo acepta chats de seguidores." + thisUserAllowsChatOnlyFromFollowing: "Este usuario sólo acepta chats de los usuarios a los que sigue." + thisUserAllowsChatOnlyFromMutualFollowing: "Este usuario sólo acepta chats de usuarios que son seguidores mutuos." + thisUserNotAllowedChatAnyone: "Este usuario no acepta chats de nadie." + chatAllowedUsers: "A quién permitir chatear." + chatAllowedUsers_note: "Puedes chatear con cualquier persona a la que hayas enviado un mensaje de chat, independientemente de esta configuración." + _chatAllowedUsers: + everyone: "Todos" + followers: "Sólo sus propios seguidores." + following: "Solo usuarios que sigues" + mutual: "Solo seguidores mutuos" + none: "Nadie" +_emojiPalette: + palettes: "Paleta\n" _settings: + api: "API" webhook: "Webhook" _accountSettings: requireSigninToViewContents: "Se requiere iniciar sesión para ver el contenido" diff --git a/locales/index.d.ts b/locales/index.d.ts index d77c4137c3..dae5d912fc 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -3935,6 +3935,10 @@ export interface Locale extends ILocale { * ログアウトしますか? */ "logoutConfirm": string; + /** + * ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。 + */ + "logoutWillClearClientData": string; /** * 最終利用日時 */ @@ -5395,6 +5399,10 @@ export interface Locale extends ILocale { * デッキへ戻る */ "goToDeck": string; + /** + * 連合ジョブ + */ + "federationJobs": string; "_chat": { /** * まだメッセージはありません @@ -9315,6 +9323,10 @@ export interface Locale extends ILocale { * 今日誕生日のユーザー */ "birthdayFollowings": string; + /** + * チャット + */ + "chat": string; /** * Search */ @@ -10406,6 +10418,10 @@ export interface Locale extends ILocale { * ロールタイムライン */ "roleTimeline": string; + /** + * チャット + */ + "chat": string; /** * Following */ diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 44b0858fe8..75691d817f 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "Escludere i Bot" antennaKeywordsDescription: "Sparando con uno spazio indichi la condizione E (and). Separando con un a capo, indichi la condizione O (or)." notifyAntenna: "Invia notifiche delle nuove note" withFileAntenna: "Solo note con file in allegato" +excludeNotesInSensitiveChannel: "Escludere le Note dai canali espliciti" enableServiceworker: "Abilita ServiceWorker" antennaUsersDescription: "Elenca un nome utente per riga" caseSensitive: "Sensibile alla distinzione tra maiuscole e minuscole" @@ -727,7 +728,7 @@ reporterOrigin: "Segnalazione da" send: "Inviare" openInNewTab: "Apri in una nuova scheda" openInSideView: "Apri in vista laterale" -defaultNavigationBehaviour: "Navigazione preimpostata" +defaultNavigationBehaviour: "Tipo di navigazione predefinita" editTheseSettingsMayBreakAccount: "Modificare queste impostazioni può danneggiare il profilo" instanceTicker: "Informazioni sull'istanza da cui vengono le note" waitingFor: "Aspettando {x}" @@ -866,7 +867,7 @@ noBotProtectionWarning: "Non è stata impostata alcuna protezione dai Bot" configure: "Imposta" postToGallery: "Pubblicare nella galleria" postToHashtag: "Pubblica a questo hashtag" -gallery: "Galleria" +gallery: "Gallerie" recentPosts: "Pubblicazioni recenti" popularPosts: "Le più visualizzate" shareWithNote: "Condividere in nota" @@ -978,6 +979,7 @@ document: "Documentazione" numberOfPageCache: "Numero di pagine cache" numberOfPageCacheDescription: "Aumenta l'usabilità, ma aumenta anche il carico e l'utilizzo della memoria." logoutConfirm: "Vuoi davvero uscire da Misskey? " +logoutWillClearClientData: "All'uscita, la configurazione del client viene rimossa dal browser. Per ripristinarla quando si effettua nuovamente l'accesso, abilitare il backup automatico." lastActiveDate: "Data dell'ultimo utilizzo" statusbar: "Barra di stato" pleaseSelect: "Scegli un'opzione" @@ -1340,6 +1342,9 @@ right: "Destra" bottom: "Sotto" top: "Sopra" embed: "Incorporare" +settingsMigrating: "Migrazione delle impostazioni. Attendere prego ... (Puoi anche migrare manualmente in un secondo momento, nel menu: Impostazioni → Altro → Migrazione delle impostazioni)" +readonly: "Sola lettura" +goToDeck: "Torna al Deck" _chat: noMessagesYet: "Ancora nessun messaggio" newMessage: "Nuovo messaggio" @@ -1369,6 +1374,7 @@ _chat: muteThisRoom: "Silenzia stanza" deleteRoom: "Elimina stanza" chatNotAvailableForThisAccountOrServer: "Questo server, o questo profilo ha disabilitato la chat." + chatIsReadOnlyForThisAccountOrServer: "Le chat, su questo server o su questo profilo, sono di sola lettura. Impossibile scrivere in chat o creare e partecipare a stanze." chatNotAvailableInOtherAccount: "La chat non è disponibile nel profilo dell'altra persona." cannotChatWithTheUser: "Impossibile chattare con questa persona" cannotChatWithTheUser_description: "La chat potrebbe non essere disponibile, oppure l'altra persona potrebbe non esserlo." @@ -1929,6 +1935,7 @@ _role: canImportFollowing: "Può importare Following" canImportMuting: "Può importare Silenziati" canImportUserLists: "Può importare liste di Profili" + chatAvailability: "Chat consentita" _condition: roleAssignedTo: "Assegnato a ruoli manualmente" isLocal: "Profilo locale" @@ -2362,6 +2369,7 @@ _widgets: chooseList: "Seleziona una lista" clicker: "Cliccheria" birthdayFollowings: "Compleanni del giorno" + chat: "Chat" _cw: hide: "Nascondere" show: "Continua la lettura..." @@ -2594,8 +2602,8 @@ _notification: renote: "Rinota" _deck: alwaysShowMainColumn: "Mostra sempre la colonna principale" - columnAlign: "Allineare colonne" - columnGap: "Margine tra le colonne" + columnAlign: "Allineamento delle colonne" + columnGap: "Spessore del margine tra colonne" deckMenuPosition: "Posizione del menu Deck" navbarPosition: "Posizione barra di navigazione" addColumn: "Aggiungi colonna" @@ -2624,10 +2632,11 @@ _deck: tl: "Timeline" antenna: "Antenne" list: "Liste" - channel: "Canale" + channel: "Canali" mentions: "Menzioni" direct: "Note Dirette" roleTimeline: "Timeline Ruolo" + chat: "Chat" _dialog: charactersExceeded: "Hai superato il limite di {max} caratteri! ({current})" charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({current})" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index c7a9fc8792..fb2590d8fd 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -979,6 +979,7 @@ document: "ドキュメント" numberOfPageCache: "ページキャッシュ数" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" logoutConfirm: "ログアウトしますか?" +logoutWillClearClientData: "ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。" lastActiveDate: "最終利用日時" statusbar: "ステータスバー" pleaseSelect: "選択してください" @@ -1344,6 +1345,7 @@ embed: "埋め込み" settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)" readonly: "読み取り専用" goToDeck: "デッキへ戻る" +federationJobs: "連合ジョブ" _chat: noMessagesYet: "まだメッセージはありません" @@ -2420,6 +2422,7 @@ _widgets: chooseList: "リストを選択" clicker: "クリッカー" birthdayFollowings: "今日誕生日のユーザー" + chat: "チャット" _cw: hide: "隠す" @@ -2704,6 +2707,7 @@ _deck: mentions: "あなた宛て" direct: "ダイレクト" roleTimeline: "ロールタイムライン" + chat: "チャット" _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index fc578758a2..fb7db80186 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -489,7 +489,7 @@ next: "다음" retype: "다시 입력" noteOf: "{user}의 노트" quoteAttached: "인용함" -quoteQuestion: "인용해서 작성하시겠습니까?" +quoteQuestion: "인용해서 첨부하시겠습니까?" attachAsFileQuestion: "붙여넣으려는 글이 너무 깁니다. 텍스트 파일로 첨부하시겠습니까?" onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하나까지입니다" signinRequired: "진행하기 전에 로그인을 해 주세요" @@ -571,8 +571,8 @@ objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" s3ForcePathStyleDesc: "s3ForcePathStyle을 활성화하면, 버킷 이름을 URL의 호스트명이 아닌 경로의 일부로써 취급합니다. 셀프 호스트 Minio와 같은 서비스를 사용할 경우 활성화해야 할 수 있습니다." serverLogs: "서버 로그" deleteAll: "모두 삭제" -showFixedPostForm: "타임라인 상단에 글 작성란을 표시" -showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란을 표시" +showFixedPostForm: "타임라인 상단에 글 입력란을 표시" +showFixedPostFormInChannel: "채널 타임라인 상단에 글 입력란을 표시" withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 나오게 하기" newNoteRecived: "새 노트가 있습니다" sounds: "소리" @@ -720,7 +720,7 @@ abuseReports: "신고" reportAbuse: "신고" reportAbuseRenote: "리노트 신고하기" reportAbuseOf: "{name} 신고하기" -fillAbuseReportDescription: "신고하려는 이유를 자세히 알려주세요. 특정 게시물을 신고할 때에는 게시물의 URL도 포함해 주세요." +fillAbuseReportDescription: "신고 사유를 자세히 기재해 주세요. 대상 노트나 페이지 등이 있는 경우에는 해당 URL도 기재해 주세요." abuseReported: "신고를 보냈습니다. 신고해 주셔서 감사합니다." reporter: "신고자" reporteeOrigin: "피신고자" @@ -825,7 +825,7 @@ editCode: "코드 수정" apply: "적용" receiveAnnouncementFromInstance: "이 서버의 알림을 이메일로 수신할게요" emailNotification: "메일 알림" -publish: "게시" +publish: "공개" inChannelSearch: "채널에서 검색" useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기" typingUsers: "{users}님이 입력 중" @@ -1081,7 +1081,7 @@ sensitiveWords: "민감한 단어" sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제합니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다." sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." prohibitedWords: "금지 단어" -prohibitedWordsDescription: "설정된 단어가 포함되는 노트를 작성하려고 하면, 오류가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다." +prohibitedWordsDescription: "설정된 단어가 포함되는 노트를 게시하려고 하면, 오류가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다." prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." hiddenTags: "숨긴 해시태그" hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다." @@ -1373,7 +1373,7 @@ _chat: muteThisRoom: "이 룸을 뮤트" deleteRoom: "룸을 삭제" chatNotAvailableForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅이 활성화되어 있지 않습니다." - chatIsReadOnlyForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅은 읽기 전용입니다. 새로 쓰거나 채팅방을 만들거나 참가할 수 없습니다." + chatIsReadOnlyForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅은 읽기 전용입니다. 새로 쓰거나 채팅 룸을 만들거나 참가할 수 없습니다." chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다." cannotChatWithTheUser: "이 유저와 채팅을 시작할 수 없습니다" cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다." @@ -1542,7 +1542,7 @@ _initialTutorial: description3: "이 외에도, '리스트 타임라인'이나 '채널 타임라인' 등이 있습니다. 자세한 사항은 {link}에서 확인하실 수 있습니다." _postNote: title: "노트 게시 설정" - description1: "Misskey에 노트를 쓸 때에는 다양한 옵션을 설정할 수 있습니다. 노트를 작성하는 화면은 이렇게 생겼습니다." + description1: "Misskey에 노트를 게시할 때에는 다양한 옵션 설정이 가능합니다. 노트를 게시할 때 쓰이는 '글 입력란'은 이렇게 생겼습니다." _visibility: description: "노트를 볼 수 있는 사람을 제한할 수 있습니다." public: "모든 유저에게 공개합니다." @@ -1562,7 +1562,7 @@ _initialTutorial: _howToMakeAttachmentsSensitive: title: "첨부 파일을 열람주의로 설정하려면?" description: "서버의 가이드라인에 따라 필요한 이미지, 또는 그대로 노출되기에 부적절한 미디어는 '열람 주의'를 설정해 주세요." - tryThisFile: "이 작성 창에 첨부된 이미지를 열람 주의로 설정해 보세요!" + tryThisFile: "이 입력란에 첨부된 이미지를 열람 주의로 설정해 보세요!" _exampleNote: note: "낫또 뚜껑 뜯다가 실수했다…" method: "첨부 파일을 열람 주의로 설정하려면, 해당 파일을 클릭하여 메뉴를 열고, '열람주의로 설정'을 클릭합니다." @@ -1816,7 +1816,7 @@ _achievements: description: "드라이브 폴더에 스스로를 넣게 했다" _reactWithoutRead: title: "읽고 답하긴 하시는 건가요?" - description: "100자가 넘는 노트를 작성한 지 3초 안에 리액션했다" + description: "100자가 넘는 노트를 게시한 지 3초 안에 리액션했다" _clickedClickHere: title: "여길 눌러보세요" description: "여기를 눌렀다" @@ -1845,7 +1845,7 @@ _achievements: _cookieClicked: title: "쿠키를 클릭하는 게임" description: "쿠키를 클릭했다" - flavor: "소프트웨어 착각하지 않았어?" + flavor: "소프트웨어 착각하지 않으셨나요?" _brainDiver: title: "Brain Diver" description: "Brain Diver로의 링크를 첨부했다" @@ -2368,6 +2368,7 @@ _widgets: chooseList: "리스트 선택" clicker: "클리커" birthdayFollowings: "오늘이 생일인 유저" + chat: "채팅" _cw: hide: "숨기기" show: "더 보기" @@ -2634,6 +2635,7 @@ _deck: mentions: "받은 멘션" direct: "다이렉트" roleTimeline: "역할 타임라인" + chat: "채팅" _dialog: charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {max}" charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index b313c4ce1f..4b78b0a362 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -979,6 +979,7 @@ document: "文档" numberOfPageCache: "缓存页数" numberOfPageCacheDescription: "设置较高的值会更方便用户,但设备的负载和内存使用量会增加。" logoutConfirm: "是否确认登出?" +logoutWillClearClientData: "登出时将会从浏览器中删除客户端的设置信息。如果想要在再次登入时恢复设置信息,请在设置里打开自动备份。" lastActiveDate: "最后活跃时间" statusbar: "状态栏" pleaseSelect: "请选择" @@ -2367,6 +2368,7 @@ _widgets: chooseList: "选择列表" clicker: "点击器" birthdayFollowings: "今天是他们的生日" + chat: "聊天" _cw: hide: "隐藏" show: "查看更多" @@ -2633,6 +2635,7 @@ _deck: mentions: "提及" direct: "指定用户" roleTimeline: "角色时间线" + chat: "聊天" _dialog: charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 57b7e84b8a..cfe3b729f0 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -424,6 +424,7 @@ antennaExcludeBots: "排除機器人帳戶" antennaKeywordsDescription: "空格代表「以及」(AND),換行代表「或者」(OR)" notifyAntenna: "通知有新貼文" withFileAntenna: "僅帶有附件的貼文" +excludeNotesInSensitiveChannel: "排除敏感頻道的貼文" enableServiceworker: "啟用瀏覽器的推播通知" antennaUsersDescription: "填寫使用者名稱,以換行分隔" caseSensitive: "區分大小寫" @@ -978,6 +979,7 @@ document: "文件" numberOfPageCache: "快取頁面數" numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。" logoutConfirm: "確定要登出嗎?" +logoutWillClearClientData: "當您登出時,客戶端的設定資訊將從瀏覽器中清除。為了能夠在重新登入時恢復您的設定資訊,請啟用設定內的自動備份選項。" lastActiveDate: "上次使用日期及時間" statusbar: "狀態列" pleaseSelect: "請選擇" @@ -1307,7 +1309,7 @@ availableRoles: "可用角色" acknowledgeNotesAndEnable: "了解注意事項後再開啟。" federationSpecified: "此伺服器以白名單聯邦的方式運作。除了管理員指定的伺服器外,它無法與其他伺服器互動。" federationDisabled: "此伺服器未開啟站台聯邦。無法與其他伺服器上的使用者互動。" -confirmOnReact: "反應時確認" +confirmOnReact: "在做出反應前先確認" reactAreYouSure: "用「 {emoji} 」反應嗎?" markAsSensitiveConfirm: "要將這個媒體設定為敏感嗎?" unmarkAsSensitiveConfirm: "要解除這個媒體的敏感設定嗎?" @@ -1444,7 +1446,7 @@ _accountSettings: makeNotesHiddenBefore: "隱藏過去的貼文" makeNotesHiddenBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對自己顯示(私密化)。 如果您再次停用它,貼文的公開狀態也會恢復原狀。" mayNotEffectForFederatedNotes: "聯邦發送至遠端伺服器的貼文可能會不受影響。" - mayNotEffectSomeSituations: "這些限制已經簡化。它們可能不適用於某些情況,例如在遠端伺服器上檢視或管理時。" + mayNotEffectSomeSituations: "這些限制僅是簡化版本。在某些情況下,例如在遠端伺服器上瀏覽或進行審核時,可能不會套用這些限制。" notesHavePassedSpecifiedPeriod: "早於指定時間的貼文" notesOlderThanSpecifiedDateAndTime: "指定時間和日期之前的貼文" _abuseUserReport: @@ -2366,6 +2368,7 @@ _widgets: chooseList: "選擇清單" clicker: "點擊器" birthdayFollowings: "今天生日的使用者" + chat: "聊天" _cw: hide: "隱藏" show: "顯示內容" @@ -2632,6 +2635,7 @@ _deck: mentions: "提及" direct: "指定使用者" roleTimeline: "角色時間軸" + chat: "聊天" _dialog: charactersExceeded: "您的貼文太長了!現時字數 {current}/限制字數 {max}" charactersBelow: "您的貼文太短了!現時字數 {current}/限制字數 {min}" diff --git a/package.json b/package.json index a5ae3374c5..76ada83b4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sharkey", - "version": "2025.4.0", + "version": "2025.4.1-alpha.2", "codename": "shonk", "repository": { "type": "git", diff --git a/packages/backend/migration/1745378064470-composite-note-index.js b/packages/backend/migration/1745378064470-composite-note-index.js new file mode 100644 index 0000000000..49e835d38c --- /dev/null +++ b/packages/backend/migration/1745378064470-composite-note-index.js @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CompositeNoteIndex1745378064470 { + name = 'CompositeNoteIndex1745378064470'; + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5b87d9d19127bd5d92026017a7"`); + // Flush all cached Linear Scan Plans and redo statistics for composite index + // this is important for Postgres to learn that even in highly complex queries, using this index first can reduce the result set significantly + await queryRunner.query(`ANALYZE "user", "note"`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); + await queryRunner.query(`CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index e67029ef7f..64a64650c6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -37,17 +37,17 @@ }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.11.11", - "@swc/core-darwin-x64": "1.11.11", + "@swc/core-darwin-arm64": "1.11.18", + "@swc/core-darwin-x64": "1.11.18", "@swc/core-freebsd-x64": "1.3.11", - "@swc/core-linux-arm-gnueabihf": "1.11.11", - "@swc/core-linux-arm64-gnu": "1.11.11", - "@swc/core-linux-arm64-musl": "1.11.11", - "@swc/core-linux-x64-gnu": "1.11.11", - "@swc/core-linux-x64-musl": "1.11.11", - "@swc/core-win32-arm64-msvc": "1.11.11", - "@swc/core-win32-ia32-msvc": "1.11.11", - "@swc/core-win32-x64-msvc": "1.11.11", + "@swc/core-linux-arm-gnueabihf": "1.11.18", + "@swc/core-linux-arm64-gnu": "1.11.18", + "@swc/core-linux-arm64-musl": "1.11.18", + "@swc/core-linux-x64-gnu": "1.11.18", + "@swc/core-linux-x64-musl": "1.11.18", + "@swc/core-win32-arm64-msvc": "1.11.18", + "@swc/core-win32-ia32-msvc": "1.11.18", + "@swc/core-win32-x64-msvc": "1.11.18", "bufferutil": "4.0.9", "slacc-android-arm-eabi": "0.0.10", "slacc-android-arm64": "0.0.10", @@ -65,8 +65,8 @@ "utf-8-validate": "6.0.5" }, "dependencies": { - "@aws-sdk/client-s3": "3.772.0", - "@aws-sdk/lib-storage": "3.772.0", + "@aws-sdk/client-s3": "3.782.0", + "@aws-sdk/lib-storage": "3.782.0", "@discordapp/twemoji": "15.1.0", "@fastify/accepts": "5.0.2", "@fastify/cookie": "11.0.2", @@ -76,11 +76,11 @@ "@fastify/multipart": "9.0.3", "@fastify/static": "8.1.1", "@fastify/view": "10.0.2", - "@misskey-dev/sharp-read-bmp": "1.2.0", + "@misskey-dev/sharp-read-bmp": "1.3.0", "@misskey-dev/summaly": "5.2.0", - "@nestjs/common": "11.0.12", - "@nestjs/core": "11.0.12", - "@nestjs/testing": "11.0.12", + "@nestjs/common": "11.0.16", + "@nestjs/core": "11.0.15", + "@nestjs/testing": "11.0.15", "@peertube/http-signature": "1.7.0", "@sentry/node": "8.55.0", "@sentry/profiling-node": "8.55.0", @@ -88,9 +88,10 @@ "@sinonjs/fake-timers": "11.3.1", "@smithy/node-http-handler": "2.5.0", "@swc/cli": "0.6.0", - "@swc/core": "1.11.11", + "@swc/core": "1.11.18", "@transfem-org/sfm-js": "0.24.6", "@twemoji/parser": "15.1.1", + "@types/redis-info": "3.0.3", "@types/psl": "^1.1.3", "accepts": "1.3.8", "ajv": "8.17.1", @@ -100,7 +101,7 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.3", - "bullmq": "5.44.1", + "bullmq": "5.48.1", "cacheable-lookup": "7.0.0", "canvas": "^3.1.0", "cbor": "9.0.2", @@ -113,7 +114,7 @@ "date-fns": "2.30.0", "deep-email-validator": "0.1.21", "fast-xml-parser": "4.4.1", - "fastify": "5.2.1", + "fastify": "5.3.2", "fastify-multer": "^2.0.3", "fastify-raw-body": "5.0.0", "feed": "4.2.2", @@ -121,7 +122,7 @@ "fluent-ffmpeg": "2.1.3", "form-data": "4.0.2", "glob": "11.0.0", - "got": "14.4.6", + "got": "14.4.7", "happy-dom": "16.8.1", "hpagent": "1.2.0", "htmlescape": "1.1.1", @@ -152,7 +153,7 @@ "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.3.6", + "otpauth": "9.4.0", "parse5": "7.2.1", "pg": "8.14.1", "pkce-challenge": "4.1.0", @@ -165,6 +166,7 @@ "random-seed": "0.3.0", "ratelimiter": "3.4.1", "re2": "1.21.4", + "redis-info": "3.1.0", "redis-lock": "0.1.4", "reflect-metadata": "0.2.2", "rename": "1.0.4", @@ -172,17 +174,17 @@ "rxjs": "7.8.2", "sanitize-html": "2.15.0", "secure-json-parse": "3.0.2", - "sharp": "0.33.5", + "sharp": "0.34.1", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "systeminformation": "5.25.11", "tinycolor2": "1.6.0", "tmp": "0.2.3", - "tsc-alias": "1.8.11", + "tsc-alias": "1.8.15", "tsconfig-paths": "4.2.0", - "typeorm": "0.3.21", - "typescript": "5.8.2", + "typeorm": "0.3.22", + "typescript": "5.8.3", "ulid": "2.4.0", "uuid": "^9.0.1", "vary": "1.1.2", @@ -193,7 +195,7 @@ "devDependencies": { "@jest/globals": "29.7.0", "@nestjs/platform-express": "10.4.15", - "@sentry/vue": "9.8.0", + "@sentry/vue": "9.12.0", "@simplewebauthn/types": "12.0.0", "@swc/jest": "0.2.37", "@types/accepts": "1.3.7", @@ -212,7 +214,7 @@ "@types/jsrsasign": "10.5.15", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "22.13.10", + "@types/node": "22.14.0", "@types/nodemailer": "6.4.17", "@types/oauth": "0.9.6", "@types/oauth2orize": "1.11.5", @@ -224,8 +226,8 @@ "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", - "@types/sanitize-html": "2.13.0", - "@types/semver": "7.5.8", + "@types/sanitize-html": "2.15.0", + "@types/semver": "7.7.0", "@types/simple-oauth2": "5.0.7", "@types/sinonjs__fake-timers": "8.1.5", "@types/tinycolor2": "1.4.6", @@ -233,9 +235,9 @@ "@types/uuid": "^9.0.4", "@types/vary": "1.1.3", "@types/web-push": "3.6.4", - "@types/ws": "8.18.0", - "@typescript-eslint/eslint-plugin": "8.27.0", - "@typescript-eslint/parser": "8.27.0", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", "aws-sdk-client-mock": "4.1.0", "cross-env": "7.0.3", "eslint-plugin-import": "2.31.0", diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 5128caff60..7bf33e13c5 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -25,6 +25,7 @@ import InstanceChart from '@/core/chart/charts/instance.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { RoleService } from '@/core/RoleService.js'; +import { AntennaService } from '@/core/AntennaService.js'; @Injectable() export class AccountMoveService { @@ -66,6 +67,7 @@ export class AccountMoveService { private queueService: QueueService, private systemAccountService: SystemAccountService, private roleService: RoleService, + private antennaService: AntennaService, ) { } @@ -127,6 +129,7 @@ export class AccountMoveService { this.deleteScheduledNotes(src), this.copyRoles(src, dst), this.updateLists(src, dst), + this.antennaService.onMoveAccount(src, dst), ]); } catch { /* skip if any error happens */ diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 13e3dcdbd8..cf696e3599 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -5,18 +5,20 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { In } from 'typeorm'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import * as Acct from '@/misc/acct.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; import type { MiAntenna } from '@/models/Antenna.js'; import type { MiNote } from '@/models/Note.js'; import type { MiUser } from '@/models/User.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import * as Acct from '@/misc/acct.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { CacheService } from './CacheService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -37,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.userListMembershipsRepository) private userListMembershipsRepository: UserListMembershipsRepository, + private cacheService: CacheService, private utilityService: UtilityService, private globalEventService: GlobalEventService, private fanoutTimelineService: FanoutTimelineService, @@ -111,9 +114,6 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise { - if (note.visibility === 'specified') return false; - if (note.visibility === 'followers') return false; - if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false; if (antenna.excludeBots && noteUser.isBot) return false; @@ -122,6 +122,18 @@ export class AntennaService implements OnApplicationShutdown { if (!antenna.withReplies && note.replyId != null) return false; + if (note.visibility === 'specified') { + if (note.userId !== antenna.userId) { + if (note.visibleUserIds == null) return false; + if (!note.visibleUserIds.includes(antenna.userId)) return false; + } + } + + if (note.visibility === 'followers') { + const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId); + if (!isFollowing && antenna.userId !== note.userId) return false; + } + if (antenna.src === 'home') { // TODO } else if (antenna.src === 'list') { @@ -212,6 +224,41 @@ export class AntennaService implements OnApplicationShutdown { return this.antennas; } + @bindThis + public async onMoveAccount(src: MiUser, dst: MiUser): Promise { + // There is a possibility for users to add the srcUser to their antennas, but it's low, so we don't check it. + + // Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list + const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase(); + const antennasToMigrate = (await this.getAntennas()).filter(antenna => { + return antenna.users.some(user => { + const { username, host } = Acct.parse(user); + return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct; + }); + }); + + if (antennasToMigrate.length === 0) return; + + const antennaIds = antennasToMigrate.map(x => x.id); + + // Update the antennas by appending dst users acct to the users list + const dstUserAcct = '@' + Acct.toString({ username: dst.username, host: dst.host }); + + await this.antennasRepository.createQueryBuilder('antenna') + .update() + .set({ + users: () => 'array_append(antenna.users, :dstUserAcct)', + }) + .where('antenna.id IN (:...antennaIds)', { antennaIds }) + .setParameters({ dstUserAcct }) + .execute(); + + // announce update to event + for (const newAntenna of await this.antennasRepository.findBy({ id: In(antennaIds) })) { + this.globalEventService.publishInternalEvent('antennaUpdated', newAntenna); + } + } + @bindThis public dispose(): void { this.redisForSub.off('message', this.onRedisMessage); diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts index b0e8cfb61c..9d294a80cb 100644 --- a/packages/backend/src/core/ChatService.ts +++ b/packages/backend/src/core/ChatService.ts @@ -232,7 +232,7 @@ export class ChatService { const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser); this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); - //this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); + this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); }, 3000); } @@ -302,7 +302,7 @@ export class ChatService { if (marker == null) continue; this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); - //this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); + this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); } }, 3000); diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index bd86a80cbd..84ca06ec1e 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -55,7 +55,7 @@ export class FanoutTimelineEndpointService { } @bindThis - private async getMiNotes(ps: TimelineOptions): Promise { + async getMiNotes(ps: TimelineOptions): Promise { // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 0ef52ee1a6..1ee3bd2275 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -6,7 +6,7 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import * as parse5 from 'parse5'; -import { Window } from 'happy-dom'; +import { type Document, type HTMLParagraphElement, Window } from 'happy-dom'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; @@ -23,6 +23,8 @@ type ChildNode = DefaultTreeAdapterMap['childNode']; const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; +export type Appender = (document: Document, body: HTMLParagraphElement) => void; + @Injectable() export class MfmService { constructor( @@ -343,7 +345,7 @@ export class MfmService { } @bindThis - public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) { + public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) { if (nodes == null) { return null; } @@ -576,6 +578,10 @@ export class MfmService { appendChildren(nodes, body); + for (const additionalAppender of additionalAppenders) { + additionalAppender(doc, body); + } + const serialized = body.outerHTML; happyDOM.close().catch(err => {}); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 5543cc080d..fd6300483f 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -640,7 +640,14 @@ export class NoteCreateService implements OnApplicationShutdown { }, { jobId: `pollEnd:${note.id}`, delay, - removeOnComplete: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 1479bb00d9..9333c1ebc5 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -22,6 +22,7 @@ type PushNotificationsTypes = { note: Packed<'Note'>; }; 'readAllNotifications': undefined; + newChatMessage: Packed<'ChatMessage'>; }; // Reduce length because push message servers have character limits diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 039c47724b..fb0fa8f28d 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; +import { MetricsTime, type JobType } from 'bullmq'; +import { parse as parseRedisInfo } from 'redis-info'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; @@ -40,6 +42,18 @@ import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; import { MiNote } from '@/models/Note.js'; +export const QUEUE_TYPES = [ + 'system', + 'endedPollNotification', + 'deliver', + 'inbox', + 'db', + 'relationship', + 'objectStorage', + 'userWebhookDeliver', + 'systemWebhookDeliver', +] as const; + @Injectable() export class QueueService { constructor( @@ -60,50 +74,58 @@ export class QueueService { this.systemQueue.add('tickCharts', { }, { repeat: { pattern: '55 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('resyncCharts', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('cleanCharts', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('aggregateRetention', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('clean', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('checkExpiredMutings', { }, { repeat: { pattern: '*/5 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('bakeBufferedReactions', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('checkModeratorsActivity', { }, { // 毎時30分に起動 repeat: { pattern: '30 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); } @@ -125,13 +147,21 @@ export class QueueService { isSharedInbox, }; - return this.deliverQueue.add(to, data, { + const label = to.replace('https://', '').replace('/inbox', ''); + + return this.deliverQueue.add(label, data, { attempts: this.config.deliverJobMaxAttempts ?? 12, backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -153,12 +183,18 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }; await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({ - name: d[0], + name: d[0].replace('https://', '').replace('/inbox', ''), data: { user, content: contentBody, @@ -179,13 +215,21 @@ export class QueueService { signature, }; - return this.inboxQueue.add('', data, { + const label = (activity.id ?? '').replace('https://', '').replace('/activity', ''); + + return this.inboxQueue.add(label, data, { attempts: this.config.inboxJobMaxAttempts ?? 8, backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -194,8 +238,14 @@ export class QueueService { return this.dbQueue.add('deleteDriveFiles', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -204,8 +254,14 @@ export class QueueService { return this.dbQueue.add('exportCustomEmojis', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -224,8 +280,14 @@ export class QueueService { return this.dbQueue.add('exportNotes', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -234,8 +296,14 @@ export class QueueService { return this.dbQueue.add('exportClips', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -244,8 +312,14 @@ export class QueueService { return this.dbQueue.add('exportFavorites', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -256,8 +330,14 @@ export class QueueService { excludeMuting, excludeInactive, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -266,8 +346,14 @@ export class QueueService { return this.dbQueue.add('exportMuting', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -276,8 +362,14 @@ export class QueueService { return this.dbQueue.add('exportBlocking', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -286,8 +378,14 @@ export class QueueService { return this.dbQueue.add('exportUserLists', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -296,8 +394,14 @@ export class QueueService { return this.dbQueue.add('exportAntennas', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -308,8 +412,14 @@ export class QueueService { fileId: fileId, withReplies, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -373,8 +483,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -384,8 +500,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -405,8 +527,14 @@ export class QueueService { name, data, opts: { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }, }; } @@ -417,8 +545,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -428,8 +562,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -439,8 +579,14 @@ export class QueueService { user: { id: user.id }, antenna, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -450,8 +596,14 @@ export class QueueService { user: { id: user.id }, soft: opts.soft, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -501,8 +653,14 @@ export class QueueService { withReplies: data.withReplies, }, opts: { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, ...opts, }, }; @@ -513,16 +671,28 @@ export class QueueService { return this.objectStorageQueue.add('deleteFile', { key: key, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @bindThis public createCleanRemoteFilesJob() { return this.objectStorageQueue.add('cleanRemoteFiles', {}, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -553,8 +723,14 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -584,21 +760,201 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @bindThis - public destroy() { - this.deliverQueue.once('cleaned', (jobs, status) => { - //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); - }); - this.deliverQueue.clean(0, 0, 'delayed'); + private getQueue(type: typeof QUEUE_TYPES[number]): Bull.Queue { + switch (type) { + case 'system': return this.systemQueue; + case 'endedPollNotification': return this.endedPollNotificationQueue; + case 'deliver': return this.deliverQueue; + case 'inbox': return this.inboxQueue; + case 'db': return this.dbQueue; + case 'relationship': return this.relationshipQueue; + case 'objectStorage': return this.objectStorageQueue; + case 'userWebhookDeliver': return this.userWebhookDeliverQueue; + case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue; + default: throw new Error(`Unrecognized queue type: ${type}`); + } + } - this.inboxQueue.once('cleaned', (jobs, status) => { - //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + @bindThis + public async queueClear(queueType: typeof QUEUE_TYPES[number], state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed') { + const queue = this.getQueue(queueType); + + if (state === '*') { + await Promise.all([ + queue.clean(0, 0, 'completed'), + queue.clean(0, 0, 'wait'), + queue.clean(0, 0, 'active'), + queue.clean(0, 0, 'paused'), + queue.clean(0, 0, 'prioritized'), + queue.clean(0, 0, 'delayed'), + queue.clean(0, 0, 'failed'), + ]); + } else { + await queue.clean(0, 0, state); + } + } + + @bindThis + public async queuePromoteJobs(queueType: typeof QUEUE_TYPES[number]) { + const queue = this.getQueue(queueType); + await queue.promoteJobs(); + } + + @bindThis + public async queueRetryJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + if (job.finishedOn != null) { + await job.retry(); + } else { + await job.promote(); + } + } + } + + @bindThis + public async queueRemoveJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + await job.remove(); + } + } + + @bindThis + private packJobData(job: Bull.Job) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : []; + stacktrace.reverse(); + + return { + id: job.id, + name: job.name, + data: job.data, + opts: job.opts, + timestamp: job.timestamp, + processedOn: job.processedOn, + processedBy: job.processedBy, + finishedOn: job.finishedOn, + progress: job.progress, + attempts: job.attemptsMade, + delay: job.delay, + failedReason: job.failedReason, + stacktrace: stacktrace, + returnValue: job.returnvalue, + isFailed: !!job.failedReason || (Array.isArray(stacktrace) && stacktrace.length > 0), + }; + } + + @bindThis + public async queueGetJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + return this.packJobData(job); + } else { + throw new Error(`Job not found: ${jobId}`); + } + } + + @bindThis + public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) { + const RETURN_LIMIT = 100; + const queue = this.getQueue(queueType); + let jobs: Bull.Job[]; + + if (search) { + jobs = await queue.getJobs(jobTypes, 0, 1000); + + jobs = jobs.filter(job => { + const jobString = JSON.stringify(job).toLowerCase(); + return search.toLowerCase().split(' ').every(term => { + return jobString.includes(term); + }); + }); + + jobs = jobs.slice(0, RETURN_LIMIT); + } else { + jobs = await queue.getJobs(jobTypes, 0, RETURN_LIMIT); + } + + return jobs.map(job => this.packJobData(job)); + } + + @bindThis + public async queueGetQueues() { + const fetchings = QUEUE_TYPES.map(async type => { + const queue = this.getQueue(type); + + const counts = await queue.getJobCounts(); + const isPaused = await queue.isPaused(); + const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK); + const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK); + + return { + name: type, + counts: counts, + isPaused, + metrics: { + completed: metrics_completed, + failed: metrics_failed, + }, + }; }); - this.inboxQueue.clean(0, 0, 'delayed'); + + return await Promise.all(fetchings); + } + + @bindThis + public async queueGetQueue(queueType: typeof QUEUE_TYPES[number]) { + const queue = this.getQueue(queueType); + const counts = await queue.getJobCounts(); + const isPaused = await queue.isPaused(); + const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK); + const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK); + const db = parseRedisInfo(await (await queue.client).info()); + + return { + name: queueType, + qualifiedName: queue.qualifiedName, + counts: counts, + isPaused, + metrics: { + completed: metrics_completed, + failed: metrics_failed, + }, + db: { + version: db.redis_version, + mode: db.redis_mode, + runId: db.run_id, + processId: db.process_id, + port: parseInt(db.tcp_port), + os: db.os, + uptime: parseInt(db.uptime_in_seconds), + memory: { + total: parseInt(db.total_system_memory) || parseInt(db.maxmemory), + used: parseInt(db.used_memory), + fragmentationRatio: parseInt(db.mem_fragmentation_ratio), + peak: parseInt(db.used_memory_peak), + }, + clients: { + connected: parseInt(db.connected_clients), + blocked: parseInt(db.blocked_clients), + }, + }, + }; } } diff --git a/packages/backend/src/core/SystemAccountService.ts b/packages/backend/src/core/SystemAccountService.ts index 3f3afe6561..1288dc6ffa 100644 --- a/packages/backend/src/core/SystemAccountService.ts +++ b/packages/backend/src/core/SystemAccountService.ts @@ -5,11 +5,14 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; +import type { OnApplicationShutdown } from '@nestjs/common'; import { DataSource, IsNull } from 'typeorm'; +import * as Redis from 'ioredis'; import bcrypt from 'bcryptjs'; import { MiLocalUser, MiUser } from '@/models/User.js'; import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js'; import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { MemoryKVCache } from '@/misc/cache.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -20,10 +23,13 @@ import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const; @Injectable() -export class SystemAccountService { +export class SystemAccountService implements OnApplicationShutdown { private cache: MemoryKVCache; constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.db) private db: DataSource, @@ -42,6 +48,31 @@ export class SystemAccountService { private idService: IdService, ) { this.cache = new MemoryKVCache(1000 * 60 * 10); // 10m + + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'metaUpdated': { + if (body.before != null && body.before.name !== body.after.name) { + for (const account of SYSTEM_ACCOUNT_TYPES) { + await this.updateCorrespondingUserProfile(account, { + name: body.after.name, + }); + } + } + break; + } + default: + break; + } + } } @bindThis @@ -151,7 +182,7 @@ export class SystemAccountService { @bindThis public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: { - name?: string; + name?: string | null; description?: MiUserProfile['description']; }): Promise { const user = await this.fetch(type); @@ -187,4 +218,15 @@ export class SystemAccountService { public async getProxyActor() { return await this.fetch('proxy'); } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + this.cache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 469b396fb0..2f8cfea7f7 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -431,8 +431,8 @@ export class WebhookTestService { name: user.name, username: user.username, host: user.host, - avatarUrl: user.avatarUrl, - avatarBlurhash: user.avatarBlurhash, + avatarUrl: user.avatarId == null ? null : user.avatarUrl, + avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash, avatarDecorations: user.avatarDecorations.map(it => ({ id: it.id, angle: it.angle, @@ -464,10 +464,10 @@ export class WebhookTestService { createdAt: new Date().toISOString(), updatedAt: user.updatedAt?.toISOString() ?? null, lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, - bannerUrl: user.bannerUrl, - bannerBlurhash: user.bannerBlurhash, - backgroundUrl: user.backgroundUrl, - backgroundBlurhash: user.backgroundBlurhash, + bannerUrl: user.bannerId == null ? null : user.bannerUrl, + bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash, + backgroundUrl: user.backgroundId == null ? null : user.backgroundUrl, + backgroundBlurhash: user.backgroundId == null ? null : user.backgroundBlurhash, listenbrainz: null, isLocked: user.isLocked, isSilenced: false, diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index 318710fa93..c4a948429a 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import * as mfm from '@transfem-org/sfm-js'; -import { MfmService } from '@/core/MfmService.js'; +import { MfmService, Appender } from '@/core/MfmService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { extractApHashtagObjects } from './models/tag.js'; @@ -25,17 +25,17 @@ export class ApMfmService { } @bindThis - public getNoteHtml(note: Pick, apAppend?: string) { + public getNoteHtml(note: Pick, additionalAppender: Appender[] = []) { let noMisskeyContent = false; - const srcMfm = (note.text ?? '') + (apAppend ?? ''); + const srcMfm = (note.text ?? ''); const parsed = mfm.parse(srcMfm); - if (!apAppend && parsed?.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { + if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { noMisskeyContent = true; } - const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : []); + const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : [], additionalAppender); return { content, diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index e6eb777f1b..8251bc3b15 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -20,7 +20,7 @@ import type { MiEmoji } from '@/models/Emoji.js'; import type { MiPoll } from '@/models/Poll.js'; import type { MiPollVote } from '@/models/PollVote.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; -import { MfmService } from '@/core/MfmService.js'; +import { MfmService, type Appender } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { MiUserKeypair } from '@/models/UserKeypair.js'; @@ -468,10 +468,24 @@ export class ApRendererService { poll = await this.pollsRepository.findOneBy({ noteId: note.id }); } - let apAppend = ''; + const apAppend: Appender[] = []; if (quote) { - apAppend += `\n\nRE: ${quote}`; + // Append quote link as `

RE: ...` + // the claas name `quote-inline` is used in non-misskey clients for styling quote notes. + // For compatibility, the span part should be kept as possible. + apAppend.push((doc, body) => { + body.appendChild(doc.createElement('br')); + body.appendChild(doc.createElement('br')); + const span = doc.createElement('span'); + span.className = 'quote-inline'; + span.appendChild(doc.createTextNode('RE: ')); + const link = doc.createElement('a'); + link.setAttribute('href', quote); + link.textContent = quote; + span.appendChild(link); + body.appendChild(span); + }); } let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index d11042d73b..16425bb6ce 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -584,8 +584,8 @@ export class UserEntityService implements OnModuleInit { name: user.name, username: user.username, host: user.host, - avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), - avatarBlurhash: user.avatarBlurhash, + avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user), + avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash), description: mastoapi ? mastoapi.description : profile ? profile.description : '', createdAt: this.idService.parse(user.id).date.toISOString(), avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ @@ -643,10 +643,10 @@ export class UserEntityService implements OnModuleInit { : null, updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, - bannerUrl: user.bannerUrl, - bannerBlurhash: user.bannerBlurhash, - backgroundUrl: user.backgroundUrl, - backgroundBlurhash: user.backgroundBlurhash, + bannerUrl: user.bannerId == null ? null : user.bannerUrl, + bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash, + backgroundUrl: user.backgroundUrl == null ? null : user.backgroundUrl, + backgroundBlurhash: user.backgroundBlurhash == null ? null : user.backgroundBlurhash, isLocked: user.isLocked, isSuspended: user.isSuspended, location: profile!.location, diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 2dabb75d83..9328e9ebae 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -10,6 +10,7 @@ import { MiUser } from './User.js'; import { MiChannel } from './Channel.js'; import type { MiDriveFile } from './DriveFile.js'; +@Index(['userId', 'id']) @Entity('note') export class MiNote { @PrimaryColumn(id()) @@ -71,7 +72,6 @@ export class MiNote { }) public cw: string | null; - @Index() @Column({ ...id(), comment: 'The ID of author.', diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 0678746e26..760ef52d2b 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -132,11 +132,13 @@ export class MiUser { @JoinColumn() public background: MiDriveFile | null; + // avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること @Column('varchar', { length: 512, nullable: true, }) public avatarUrl: string | null; + // bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること @Column('varchar', { length: 512, nullable: true, }) @@ -147,11 +149,13 @@ export class MiUser { }) public backgroundUrl: string | null; + // avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること @Column('varchar', { length: 128, nullable: true, }) public avatarBlurhash: string | null; + // bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること @Column('varchar', { length: 128, nullable: true, }) diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 0ec619f38d..225e8ac025 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -3,30 +3,48 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; +import { + FindOneOptions, + InsertQueryBuilder, + ObjectLiteral, + QueryRunner, + Repository, + SelectQueryBuilder, +} from 'typeorm'; +import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; -import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; -import { SkLatestNote } from '@/models/LatestNote.js'; -import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import { + RawSqlResultsToEntityTransformer, +} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; +import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; -import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; +import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiBlocking } from '@/models/Blocking.js'; -import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; +import { MiChannel } from '@/models/Channel.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; +import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +import { MiChatApproval } from '@/models/ChatApproval.js'; +import { MiChatMessage } from '@/models/ChatMessage.js'; +import { MiChatRoom } from '@/models/ChatRoom.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; +import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; import { MiClip } from '@/models/Clip.js'; -import { MiClipNote } from '@/models/ClipNote.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; +import { MiClipNote } from '@/models/ClipNote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiFlash } from '@/models/Flash.js'; +import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -36,10 +54,10 @@ import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; -import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; +import { MiNoteSchedule } from '@/models/NoteSchedule.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; import { MiPage } from '@/models/Page.js'; import { MiPageLike } from '@/models/PageLike.js'; @@ -51,47 +69,43 @@ import { MiPromoRead } from '@/models/PromoRead.js'; import { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; import { MiRegistryItem } from '@/models/RegistryItem.js'; import { MiRelay } from '@/models/Relay.js'; +import { MiRenoteMuting } from '@/models/RenoteMuting.js'; +import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; +import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiRole } from '@/models/Role.js'; +import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiSignin } from '@/models/Signin.js'; import { MiSwSubscription } from '@/models/SwSubscription.js'; import { MiSystemAccount } from '@/models/SystemAccount.js'; +import { MiSystemWebhook } from '@/models/SystemWebhook.js'; import { MiUsedUsername } from '@/models/UsedUsername.js'; import { MiUser } from '@/models/User.js'; import { MiUserIp } from '@/models/UserIp.js'; import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserList } from '@/models/UserList.js'; +import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiUserListMembership } from '@/models/UserListMembership.js'; +import { MiUserMemo } from '@/models/UserMemo.js'; import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserPending } from '@/models/UserPending.js'; import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; -import { MiUserMemo } from '@/models/UserMemo.js'; import { MiWebhook } from '@/models/Webhook.js'; -import { MiSystemWebhook } from '@/models/SystemWebhook.js'; -import { MiChannel } from '@/models/Channel.js'; -import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; -import { MiRole } from '@/models/Role.js'; -import { MiRoleAssignment } from '@/models/RoleAssignment.js'; -import { MiFlash } from '@/models/Flash.js'; -import { MiFlashLike } from '@/models/FlashLike.js'; -import { MiUserListFavorite } from '@/models/UserListFavorite.js'; +import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; import { NoteEdit } from '@/models/NoteEdit.js'; -import { MiChatMessage } from '@/models/ChatMessage.js'; -import { MiChatRoom } from '@/models/ChatRoom.js'; -import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; -import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; -import { MiChatApproval } from '@/models/ChatApproval.js'; -import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; -import { MiReversiGame } from '@/models/ReversiGame.js'; -import { MiNoteSchedule } from '@/models/NoteSchedule.js'; import { SkApInboxLog } from '@/models/SkApInboxLog.js'; import { SkApFetchLog } from '@/models/SkApFetchLog.js'; import { SkApContext } from '@/models/SkApContext.js'; -import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; export interface MiRepository { createTableColumnNames(this: Repository & MiRepository): string[]; + insertOne(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>): Promise; + + insertOneImpl(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>, queryRunner?: QueryRunner): Promise; + selectAliasColumnNames(this: Repository & MiRepository, queryBuilder: InsertQueryBuilder, builder: SelectQueryBuilder): void; } @@ -100,6 +114,21 @@ export const miRepository = { return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName); }, async insertOne(entity, findOptions?) { + const opt = this.manager.connection.options as PostgresConnectionOptions; + if (opt.replication) { + const queryRunner = this.manager.connection.createQueryRunner('master'); + try { + return this.insertOneImpl(entity, findOptions, queryRunner); + } finally { + await queryRunner.release(); + } + } else { + return this.insertOneImpl(entity, findOptions); + } + }, + async insertOneImpl(entity, findOptions?, queryRunner?) { + // ---- insert + returningの結果を共通テーブル式(CTE)に保持するクエリを生成 ---- + const queryBuilder = this.createQueryBuilder().insert().values(entity); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const mainAlias = queryBuilder.expressionMap.mainAlias!; @@ -107,7 +136,9 @@ export const miRepository = { mainAlias.name = 't'; const columnNames = this.createTableColumnNames(); queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2)); - const builder = this.createQueryBuilder().addCommonTableExpression(queryBuilder, 'cte', { columnNames }); + + // ---- 共通テーブル式(CTE)から結果を取得 ---- + const builder = this.createQueryBuilder(undefined, queryRunner).addCommonTableExpression(queryBuilder, 'cte', { columnNames }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion builder.expressionMap.mainAlias!.tablePath = 'cte'; this.selectAliasColumnNames(queryBuilder, builder); @@ -216,7 +247,9 @@ export { }; export type AbuseUserReportsRepository = Repository & MiRepository; -export type AbuseReportNotificationRecipientRepository = Repository & MiRepository; +export type AbuseReportNotificationRecipientRepository = + Repository + & MiRepository; export type AccessTokensRepository = Repository & MiRepository; export type AdsRepository = Repository & MiRepository; export type AnnouncementsRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index ffc2daa62c..632fd58927 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -5,7 +5,7 @@ // https://github.com/typeorm/typeorm/issues/2400 import pg from 'pg'; -import { DataSource, Logger } from 'typeorm'; +import { DataSource, Logger, type QueryRunner } from 'typeorm'; import * as highlight from 'cli-highlight'; import { entities as charts } from '@/core/chart/entities.js'; import { Config } from '@/config.js'; @@ -102,6 +102,7 @@ const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); export type LoggerProps = { disableQueryTruncation?: boolean; enableQueryParamLogging?: boolean; + printReplicationMode?: boolean, }; function highlightSql(sql: string) { @@ -127,8 +128,10 @@ class MyCustomLogger implements Logger { } @bindThis - private transformQueryLog(sql: string) { - let modded = sql; + private transformQueryLog(sql: string, opts?: { + prefix?: string; + }) { + let modded = opts?.prefix ? opts.prefix + sql : sql; if (!this.props.disableQueryTruncation) { modded = truncateSql(modded); } @@ -146,18 +149,27 @@ class MyCustomLogger implements Logger { } @bindThis - public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); } @bindThis - public logQueryError(error: string, query: string, parameters?: any[]) { - sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + sqlLogger.error(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); } @bindThis - public logQuerySlow(time: number, query: string, parameters?: any[]) { - sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + sqlLogger.warn(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); } @bindThis @@ -306,6 +318,7 @@ export function createPostgresDataSource(config: Config) { ? new MyCustomLogger({ disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, + printReplicationMode: !!config.dbReplications, }) : undefined, maxQueryExecutionTime: 300, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 297edfd545..90aaf67a84 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -47,7 +47,7 @@ import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; -import { QUEUE, baseQueueOptions } from './const.js'; +import { QUEUE, baseWorkerOptions } from './const.js'; import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js'; // ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 @@ -186,7 +186,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM), + ...baseWorkerOptions(this.config, QUEUE.SYSTEM), autorun: false, }); @@ -251,7 +251,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.DB), + ...baseWorkerOptions(this.config, QUEUE.DB), autorun: false, }); @@ -283,7 +283,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.deliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.DELIVER), + ...baseWorkerOptions(this.config, QUEUE.DELIVER), autorun: false, concurrency: this.config.deliverJobConcurrency ?? 128, limiter: { @@ -323,7 +323,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.inboxProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.INBOX), + ...baseWorkerOptions(this.config, QUEUE.INBOX), autorun: false, concurrency: this.config.inboxJobConcurrency ?? 16, limiter: { @@ -363,7 +363,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.userWebhookDeliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), + ...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), autorun: false, concurrency: 64, limiter: { @@ -403,7 +403,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.systemWebhookDeliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), + ...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), autorun: false, concurrency: 16, limiter: { @@ -453,7 +453,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.RELATIONSHIP), + ...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP), autorun: false, concurrency: this.config.relationshipJobConcurrency ?? 16, limiter: { @@ -498,7 +498,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE), + ...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE), autorun: false, concurrency: 16, }); @@ -531,7 +531,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.endedPollNotificationProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), + ...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), autorun: false, }); } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index fdf012f149..17c6b81736 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MetricsTime } from 'bullmq'; import { Config } from '@/config.js'; import type * as Bull from 'bullmq'; @@ -28,3 +29,12 @@ export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof t prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`, }; } + +export function baseWorkerOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.WorkerOptions { + return { + ...baseQueueOptions(config, queueName), + metrics: { + maxDataPoints: MetricsTime.ONE_WEEK, + }, + }; +} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index c7aa694964..95ee78d660 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -38,6 +38,7 @@ import * as Acct from '@/misc/acct.js'; import { CacheService } from '@/core/CacheService.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; @@ -85,6 +86,7 @@ export class ActivityPubServerService { private queueService: QueueService, private userKeypairService: UserKeypairService, private queryService: QueryService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private loggerService: LoggerService, private readonly cacheService: CacheService, ) { @@ -613,16 +615,28 @@ export class ActivityPubServerService { const partOf = `${this.config.url}/users/${userId}/outbox`; if (page) { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) - .andWhere('note.userId = :userId', { userId: user.id }) - .andWhere(new Brackets(qb => { - qb - .where('note.visibility = \'public\'') - .orWhere('note.visibility = \'home\''); - })) - .andWhere('note.localOnly = FALSE'); - - const notes = await query.limit(limit).getMany(); + const notes = this.meta.enableFanoutTimeline ? await this.fanoutTimelineEndpointService.getMiNotes({ + sinceId: sinceId ?? null, + untilId: untilId ?? null, + limit: limit, + allowPartial: false, // Possibly true? IDK it's OK for ordered collection. + me: null, + redisTimelines: [ + `userTimeline:${user.id}`, + `userTimelineWithReplies:${user.id}`, + ], + useDbFallback: true, + ignoreAuthorFromMute: true, + excludePureRenotes: false, + noteFilter: (note) => { + if (note.visibility !== 'home' && note.visibility !== 'public') return false; + if (note.localOnly) return false; + return true; + }, + dbFallback: async (untilId, sinceId, limit) => { + return await this.getUserNotesFromDb(sinceId, untilId, limit, user.id); + }, + }) : await this.getUserNotesFromDb(sinceId ?? null, untilId ?? null, limit, user.id); if (sinceId) notes.reverse(); @@ -659,6 +673,20 @@ export class ActivityPubServerService { } } + @bindThis + private async getUserNotesFromDb(untilId: string | null, sinceId: string | null, limit: number, userId: MiUser['id']) { + return await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) + .andWhere('note.userId = :userId', { userId }) + .andWhere(new Brackets(qb => { + qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + .andWhere('note.localOnly = FALSE') + .limit(limit) + .getMany(); + } + @bindThis private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null, redact = false) { if (this.meta.federation === 'none') { diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index c90c206e94..dce47e2290 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -223,7 +223,7 @@ export class ServerService implements OnApplicationShutdown { reply.header('Cache-Control', 'public, max-age=86400'); if (user) { - reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); + reply.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user)); } else { reply.redirect('/static-assets/user-unknown.png'); } diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index 25fa725320..f48310c50f 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -138,7 +138,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => { const fromAcct = (acct: Acct.Acct): FindOptionsWhere | number => !acct.host || acct.host === this.config.host.toLowerCase() ? { - usernameLower: acct.username, + usernameLower: acct.username.toLowerCase(), host: IsNull(), isSuspended: false, } : 422; diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index e48b3de8ee..1c5a781fd9 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -72,8 +72,14 @@ export * as 'admin/promo/create' from './endpoints/admin/promo/create.js'; export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js'; export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-delayed.js'; export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-delayed.js'; -export * as 'admin/queue/promote' from './endpoints/admin/queue/promote.js'; +export * as 'admin/queue/retry-job' from './endpoints/admin/queue/retry-job.js'; +export * as 'admin/queue/remove-job' from './endpoints/admin/queue/remove-job.js'; +export * as 'admin/queue/show-job' from './endpoints/admin/queue/show-job.js'; +export * as 'admin/queue/promote-jobs' from './endpoints/admin/queue/promote-jobs.js'; +export * as 'admin/queue/jobs' from './endpoints/admin/queue/jobs.js'; export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js'; +export * as 'admin/queue/queues' from './endpoints/admin/queue/queues.js'; +export * as 'admin/queue/queue-stats' from './endpoints/admin/queue/queue-stats.js'; export * as 'admin/reject-quotes' from './endpoints/admin/reject-quotes.js'; export * as 'admin/relays/add' from './endpoints/admin/relays/add.js'; export * as 'admin/relays/list' from './endpoints/admin/relays/list.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts index 3f7df0e63d..81cb4b8119 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { QueueService } from '@/core/QueueService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -18,8 +18,11 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, - required: [], + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + state: { type: 'string', enum: ['*', 'completed', 'wait', 'active', 'paused', 'prioritized', 'delayed', 'failed'] }, + }, + required: ['queue', 'state'], } as const; @Injectable() @@ -29,7 +32,7 @@ export default class extends Endpoint { // eslint- private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { - this.queueService.destroy(); + this.queueService.queueClear(ps.queue, ps.state); this.moderationLogService.log(me, 'clearQueue'); }); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts new file mode 100644 index 0000000000..79731c9786 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed'] } }, + search: { type: 'string' }, + }, + required: ['queue', 'state'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetJobs(ps.queue, ps.state, ps.search); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts new file mode 100644 index 0000000000..d22385e261 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + }, + required: ['queue'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.queuePromoteJobs(ps.queue); + + this.moderationLogService.log(me, 'promoteQueue'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts deleted file mode 100644 index 7502d4e1f7..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { QueueService } from '@/core/QueueService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireModerator: true, - kind: 'write:admin:queue', -} as const; - -export const paramDef = { - type: 'object', - properties: { - type: { type: 'string', enum: ['deliver', 'inbox'] }, - }, - required: ['type'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private moderationLogService: ModerationLogService, - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - let delayedQueues; - - switch (ps.type) { - case 'deliver': - delayedQueues = await this.queueService.deliverQueue.getDelayed(); - for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { - const queue = delayedQueues[queueIndex]; - try { - await queue.promote(); - } catch (e) { - if (e instanceof Error) { - if (e.message.indexOf('not in a delayed state') !== -1) { - throw e; - } - } else { - throw e; - } - } - } - break; - - case 'inbox': - delayedQueues = await this.queueService.inboxQueue.getDelayed(); - for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { - const queue = delayedQueues[queueIndex]; - try { - await queue.promote(); - } catch (e) { - if (e instanceof Error) { - if (e.message.indexOf('not in a delayed state') !== -1) { - throw e; - } - } else { - throw e; - } - } - } - break; - } - - this.moderationLogService.log(me, 'promoteQueue'); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts new file mode 100644 index 0000000000..10ce48332a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + }, + required: ['queue'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetQueue(ps.queue); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queues.ts b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts new file mode 100644 index 0000000000..3a38275f60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetQueues(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts new file mode 100644 index 0000000000..2c73f689d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.queueRemoveJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts new file mode 100644 index 0000000000..b2603128f8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.queueRetryJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts new file mode 100644 index 0000000000..63747b5540 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 34b69fd7b2..1321cf6338 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -558,7 +558,7 @@ export class ClientServerService { return await reply.view('user', { user, profile, me, - avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), + avatarUrl: _user.avatarUrl, sub: request.params.sub, ...await this.generateCommonPugData(this.meta), clientCtx: htmlSafeJsonStringify({ diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index 664f21d308..dcd4d80303 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -65,7 +65,7 @@ export class FeedService { generator: 'Sharkey', description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, link: author.link, - image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), + image: (user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user), feedLinks: { json: `${author.link}.json`, atom: `${author.link}.atom`, diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts index 83dcb8df44..ee69e857bc 100644 --- a/packages/backend/test-federation/test/user.test.ts +++ b/packages/backend/test-federation/test/user.test.ts @@ -381,7 +381,8 @@ describe('User', () => { await alice.client.request('i/delete-account', { password: alice.password }); // NOTE: user deletion query is slow - await sleep(4000); + // FIXME: ensure user is removed successfully + await sleep(10000); const following = await bob.client.request('users/following', { userId: bob.id }); strictEqual(following.length, 0); // no following relation @@ -480,7 +481,8 @@ describe('User', () => { await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); // NOTE: user deletion query is slow - await sleep(4000); + // FIXME: ensure user is removed successfully + await sleep(10000); const following = await bob.client.request('users/following', { userId: bob.id }); strictEqual(following.length, 0); // no following relation diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index cbb18fe4ed..1bbacd065b 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -6,7 +6,6 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { api, failedApiCall, @@ -19,6 +18,7 @@ import { userList, } from '../utils.js'; import type * as misskey from 'misskey-js'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { return selector(a).localeCompare(selector(b)); @@ -235,11 +235,11 @@ describe('アンテナ', () => { await failedApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] }, - user: alice + user: alice, }, { status: 400, code: 'EMPTY_KEYWORD', - id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a' + id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a', }); }); //#endregion @@ -274,11 +274,11 @@ describe('アンテナ', () => { await failedApiCall({ endpoint: 'antennas/update', parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] }, - user: alice + user: alice, }, { status: 400, code: 'EMPTY_KEYWORD', - id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4' + id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4', }); }); @@ -375,14 +375,23 @@ describe('アンテナ', () => { ], }, { - // https://github.com/misskey-dev/misskey/issues/9025 - label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。', + label: 'フォロワー限定投稿とDM投稿を含む', parameters: () => ({}), posts: [ { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true }, { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) }, + { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }), included: true }, + ], + }, + { + label: 'フォロワー限定投稿とDM投稿を含まない', + parameters: () => ({}), + posts: [ + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'public' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'home' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'followers' }) }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [carol.id] }) }, ], }, { diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index 81da0fac31..a79655c9aa 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -44,7 +44,7 @@ describe('AnnouncementService', () => { return usersRepository.insert({ id: genAidx(Date.now()), username: un, - usernameLower: un, + usernameLower: un.toLowerCase(), ...data, }) .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts index 2b7afb02f9..5bc86f6616 100644 --- a/packages/backend/test/unit/SigninWithPasskeyApiService.ts +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -123,8 +123,8 @@ describe('SigninWithPasskeyApiService', () => { jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify); const dummyUser = { - id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null, - }; + id: uid, username: uid, usernameLower: uid.toLowerCase(), uri: null, host: null, + }; const dummyProfile = { userId: uid, password: 'qwerty', diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index 6b7eedff55..ce3f931bb0 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -74,7 +74,7 @@ describe('UserEntityService', () => { ...userData, id: genAidx(Date.now()), username: un, - usernameLower: un, + usernameLower: un.toLowerCase(), }) .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 297a06fd8a..472e4d7c94 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -22,33 +22,33 @@ "astring": "1.9.0", "buraha": "0.0.1", "estree-walker": "3.0.3", - "misskey-js": "workspace:*", "frontend-shared": "workspace:*", - "punycode.js": "2.3.1", - "rollup": "4.36.0", - "sass": "1.86.0", - "shiki": "3.2.1", - "tinycolor2": "1.6.0", - "tsc-alias": "1.8.11", - "tsconfig-paths": "4.2.0", - "typescript": "5.8.2", - "uuid": "11.1.0", "json5": "2.2.3", - "vite": "6.2.4", + "misskey-js": "workspace:*", + "punycode.js": "2.3.1", + "rollup": "4.39.0", + "sass": "1.86.3", + "shiki": "3.2.2", + "tinycolor2": "1.6.0", + "tsc-alias": "1.8.15", + "tsconfig-paths": "4.2.0", + "typescript": "5.8.3", + "uuid": "11.1.0", + "vite": "6.3.1", "vue": "3.5.13" }, "devDependencies": { "@misskey-dev/summaly": "5.2.0", "@testing-library/vue": "8.1.0", - "@types/estree": "1.0.6", + "@types/estree": "1.0.7", "@types/micromatch": "4.0.9", - "@types/node": "22.13.11", + "@types/node": "22.14.0", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", - "@types/ws": "8.18.0", - "@typescript-eslint/eslint-plugin": "8.27.0", - "@typescript-eslint/parser": "8.27.0", - "@vitest/coverage-v8": "3.0.9", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", + "@vitest/coverage-v8": "3.1.1", "@vue/runtime-core": "3.5.13", "acorn": "8.14.1", "cross-env": "7.0.3", @@ -64,7 +64,7 @@ "start-server-and-test": "2.0.11", "vite-plugin-turbosnap": "1.0.3", "vue-component-type-helpers": "2.2.8", - "vue-eslint-parser": "10.1.1", + "vue-eslint-parser": "10.1.3", "vue-tsc": "2.2.8" } } diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index b9769cc670..a4874b8f77 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -21,14 +21,14 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "22.13.11", - "@typescript-eslint/eslint-plugin": "8.27.0", - "@typescript-eslint/parser": "8.27.0", - "esbuild": "0.25.1", + "@types/node": "22.14.0", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", + "esbuild": "0.25.2", "eslint-plugin-vue": "10.0.0", "nodemon": "3.1.9", - "typescript": "5.8.2", - "vue-eslint-parser": "10.1.1" + "typescript": "5.8.3", + "vue-eslint-parser": "10.1.3" }, "files": [ "js-built" diff --git a/packages/frontend-shared/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5 index 57ec7b98b1..1c52dac9d5 100644 --- a/packages/frontend-shared/themes/_dark.json5 +++ b/packages/frontend-shared/themes/_dark.json5 @@ -34,6 +34,8 @@ navFg: '@fg', navActive: '@accent', navIndicator: '@indicator', + pageHeaderBg: '@bg', + pageHeaderFg: '@fg', link: '#44a4c1', hashtag: '#ff9156', mention: '@accent', diff --git a/packages/frontend-shared/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5 index 23c8f39af7..c12f5be9db 100644 --- a/packages/frontend-shared/themes/_light.json5 +++ b/packages/frontend-shared/themes/_light.json5 @@ -34,6 +34,8 @@ navFg: '@fg', navActive: '@accent', navIndicator: '@indicator', + pageHeaderBg: '@bg', + pageHeaderFg: '@fg', link: '#44a4c1', hashtag: '#ff9156', mention: '@accent', diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 377d26d6a3..7288dabb60 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -43,6 +43,41 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl: }; } +export function chatMessage(room = false, id = 'somechatmessageid', text = 'Hello!'): entities.ChatMessage { + const fromUser = userLite(); + const toRoom = chatRoom(); + const toUser = userLite('touserid'); + return { + id, + createdAt: '2016-12-28T22:49:51.000Z', + fromUserId: fromUser.id, + fromUser, + text, + isRead: false, + reactions: [], + ...room ? { + toRoomId: toRoom.id, + toRoom, + } : { + toUserId: toUser.id, + toUser, + }, + }; +} + +export function chatRoom(id = 'somechatroomid', name = 'Some Chat Room'): entities.ChatRoom { + const owner = userLite('someownerid'); + return { + id, + createdAt: '2016-12-28T22:49:51.000Z', + ownerId: owner.id, + owner, + name, + description: 'A chat room for testing', + isMuted: false, + }; +} + export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip { return { id, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 489dab733f..1718dd93a1 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -25,7 +25,7 @@ "@rollup/plugin-replace": "6.0.2", "@rollup/pluginutils": "5.1.4", "@ruffle-rs/ruffle": "0.1.0-nightly.2024.10.15", - "@sentry/vue": "9.8.0", + "@sentry/vue": "9.12.0", "@syuilo/aiscript": "0.19.0", "@transfem-org/sfm-js": "0.24.6", "@twemoji/parser": "15.1.1", @@ -33,7 +33,7 @@ "@vue/compiler-sfc": "3.5.13", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", "astring": "1.9.0", - "broadcast-channel": "7.0.0", + "broadcast-channel": "7.1.0", "buraha": "0.0.1", "canvas-confetti": "1.9.3", "chart.js": "4.4.8", @@ -41,7 +41,7 @@ "chartjs-chart-matrix": "2.1.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.2.0", - "chromatic": "11.27.0", + "chromatic": "11.28.0", "compare-versions": "6.1.1", "cropperjs": "2.0.0", "date-fns": "4.1.0", @@ -61,21 +61,21 @@ "moment": "^2.30.1", "photoswipe": "5.4.4", "punycode.js": "2.3.1", - "rollup": "4.36.0", + "rollup": "4.39.0", "sanitize-html": "2.15.0", - "sass": "1.86.0", - "shiki": "3.2.1", + "sass": "1.86.3", + "shiki": "3.2.2", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.174.0", + "three": "0.175.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.11", + "tsc-alias": "1.8.15", "tsconfig-paths": "4.2.0", - "typescript": "5.8.2", + "typescript": "5.8.3", "uuid": "11.1.0", "v-code-diff": "1.13.1", - "vite": "6.2.4", + "vite": "6.3.1", "vue": "3.5.13", "vuedraggable": "next", "wanakana": "5.3.1" @@ -85,40 +85,40 @@ }, "devDependencies": { "@misskey-dev/summaly": "5.2.0", - "@storybook/addon-actions": "8.6.7", - "@storybook/addon-essentials": "8.6.7", - "@storybook/addon-interactions": "8.6.7", - "@storybook/addon-links": "8.6.7", - "@storybook/addon-mdx-gfm": "8.6.7", - "@storybook/addon-storysource": "8.6.7", - "@storybook/blocks": "8.6.7", - "@storybook/components": "8.6.7", - "@storybook/core-events": "8.6.7", - "@storybook/manager-api": "8.6.7", - "@storybook/preview-api": "8.6.7", - "@storybook/react": "8.6.7", - "@storybook/react-vite": "8.6.7", - "@storybook/test": "8.6.7", - "@storybook/theming": "8.6.7", - "@storybook/types": "8.6.7", - "@storybook/vue3": "8.6.7", - "@storybook/vue3-vite": "8.6.7", + "@storybook/addon-actions": "8.6.12", + "@storybook/addon-essentials": "8.6.12", + "@storybook/addon-interactions": "8.6.12", + "@storybook/addon-links": "8.6.12", + "@storybook/addon-mdx-gfm": "8.6.12", + "@storybook/addon-storysource": "8.6.12", + "@storybook/blocks": "8.6.12", + "@storybook/components": "8.6.12", + "@storybook/core-events": "8.6.12", + "@storybook/manager-api": "8.6.12", + "@storybook/preview-api": "8.6.12", + "@storybook/react": "8.6.12", + "@storybook/react-vite": "8.6.12", + "@storybook/test": "8.6.12", + "@storybook/theming": "8.6.12", + "@storybook/types": "8.6.12", + "@storybook/vue3": "8.6.12", + "@storybook/vue3-vite": "8.6.12", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", - "@types/estree": "1.0.6", + "@types/estree": "1.0.7", "@types/katex": "^0.16.7", "@types/matter-js": "0.19.8", "@types/micromatch": "4.0.9", - "@types/node": "22.13.11", + "@types/node": "22.14.0", "@types/punycode.js": "npm:@types/punycode@2.1.4", - "@types/sanitize-html": "2.13.0", + "@types/sanitize-html": "2.15.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@types/ws": "8.18.0", - "@typescript-eslint/eslint-plugin": "8.27.0", - "@typescript-eslint/parser": "8.27.0", - "@vitest/coverage-v8": "3.0.9", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", + "@vitest/coverage-v8": "3.1.1", "@vue/compiler-core": "3.5.13", "@vue/runtime-core": "3.5.13", "acorn": "8.14.1", @@ -134,18 +134,17 @@ "msw-storybook-addon": "2.0.4", "nodemon": "3.1.9", "prettier": "3.5.3", - "react": "19.0.0", - "react-dom": "19.0.0", + "react": "19.1.0", + "react-dom": "19.1.0", "seedrandom": "3.0.5", "start-server-and-test": "2.0.11", - "storybook": "8.6.7", + "storybook": "8.6.12", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", - "vite-node": "3.0.9", "vite-plugin-turbosnap": "1.0.3", - "vitest": "3.0.9", + "vitest": "3.1.1", "vitest-fetch-mock": "0.4.5", "vue-component-type-helpers": "2.2.8", - "vue-eslint-parser": "10.1.1", + "vue-eslint-parser": "10.1.3", "vue-tsc": "2.2.8" } } diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts index 032455bd70..d535c4c313 100644 --- a/packages/frontend/src/accounts.ts +++ b/packages/frontend/src/accounts.ts @@ -21,14 +21,19 @@ type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; export async function getAccounts(): Promise<{ host: string; - user: Misskey.entities.User; + id: Misskey.entities.User['id']; + username: Misskey.entities.User['username']; + user?: Misskey.entities.User | null; token: string | null; }[]> { const tokens = store.s.accountTokens; + const accountInfos = store.s.accountInfos; const accounts = prefer.s.accounts; return accounts.map(([host, user]) => ({ host, - user, + id: user.id, + username: user.username, + user: accountInfos[host + '/' + user.id], token: tokens[host + '/' + user.id] ?? null, })); } @@ -36,7 +41,8 @@ export async function getAccounts(): Promise<{ async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) { if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) { store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token }); - prefer.commit('accounts', [...prefer.s.accounts, [host, user]]); + store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user }); + prefer.commit('accounts', [...prefer.s.accounts, [host, { id: user.id, username: user.username }]]); } } @@ -44,6 +50,10 @@ export async function removeAccount(host: string, id: AccountWithToken['id']) { const tokens = JSON.parse(JSON.stringify(store.s.accountTokens)); delete tokens[host + '/' + id]; store.set('accountTokens', tokens); + const accountInfos = JSON.parse(JSON.stringify(store.s.accountInfos)); + delete accountInfos[host + '/' + id]; + store.set('accountInfos', accountInfos); + prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id)); } @@ -128,14 +138,7 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) { for (const [key, value] of Object.entries(accountData)) { $i[key] = value; } - prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { - // TODO: $iのホストも比較したいけど通常null - if (user.id === $i.id) { - return [host, $i]; - } else { - return [host, user]; - } - })); + store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i }); $i.token = token; miLocalStorage.setItem('account', JSON.stringify($i)); } @@ -145,17 +148,9 @@ export function updateCurrentAccountPartial(accountData: Partial { - // TODO: $iのホストも比較したいけど通常null - if (user.id === $i.id) { - const newUser = JSON.parse(JSON.stringify($i)); - for (const [key, value] of Object.entries(accountData)) { - newUser[key] = value; - } - return [host, newUser]; - } - return [host, user]; - })); + + store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i }); + miLocalStorage.setItem('account', JSON.stringify($i)); } @@ -230,25 +225,42 @@ export async function openAccountMenu(opts: { }, ev: MouseEvent) { if (!$i) return; - function createItem(host: string, account: Misskey.entities.User): MenuItem { - return { - type: 'user' as const, - user: account, - active: opts.active != null ? opts.active === account.id : false, - action: async () => { - if (opts.onChoose) { - opts.onChoose(account); - } else { - switchAccount(host, account.id); - } - }, - }; + function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.User | null | undefined, token: string): MenuItem { + if (account) { + return { + type: 'user' as const, + user: account, + active: opts.active != null ? opts.active === id : false, + action: async () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(host, id); + } + }, + }; + } else { + return { + type: 'button' as const, + text: username, + active: opts.active != null ? opts.active === id : false, + action: async () => { + if (opts.onChoose) { + fetchAccount(token, id).then(account => { + opts.onChoose(account); + }); + } else { + switchAccount(host, id); + } + }, + }; + } } const menuItems: MenuItem[] = []; // TODO: $iのホストも比較したいけど通常null - const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.user.id !== $i.id))).map(a => createItem(a.host, a.user)); + const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token)); if (opts.withExtraOperation) { menuItems.push({ @@ -261,7 +273,7 @@ export async function openAccountMenu(opts: { }); if (opts.includeCurrentAccount) { - menuItems.push(createItem(host, $i)); + menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token)); } menuItems.push(...accountItems); @@ -302,7 +314,7 @@ export async function openAccountMenu(opts: { }); } else { if (opts.includeCurrentAccount) { - menuItems.push(createItem(host, $i)); + menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token)); } menuItems.push(...accountItems); diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index 00bf8e68d9..b3331d742b 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -157,7 +157,7 @@ async function init() { const accounts = await getAccounts(); - const accountIdsToFetch = accounts.map(a => a.user.id).filter(id => !users.value.has(id)); + const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id)); if (accountIdsToFetch.length > 0) { const usersRes = await misskeyApi('users/show', { @@ -169,7 +169,7 @@ async function init() { users.value.set(user.id, { ...user, - token: accounts.find(a => a.user.id === user.id)!.token, + token: accounts.find(a => a.id === user.id)!.token, }); } } diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 321b043d9e..6608eeaa47 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
  • {{ i18n.ts.selectUser }}
  • -
      +
      1. {{ hashtag }}
      -
        +
        1. @@ -30,12 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only ({{ emoji.aliasOf }})
        -
          +
          1. {{ tag }}
          -
            +
            1. {{ param }}
            2. @@ -58,12 +58,44 @@ import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { searchEmoji } from '@/utility/search-emoji.js'; +import { searchEmoji, searchEmojiExact } from '@/utility/search-emoji.js'; import { prefer } from '@/preferences.js'; +export type CompleteInfo = { + user: { + payload: any; + query: string | null; + }, + hashtag: { + payload: string; + query: string; + }, + // `:emo` -> `:emoji:` or some unicode emoji + emoji: { + payload: string; + query: string; + }, + // like emoji but for `:emoji:` -> unicode emoji + emojiComplete: { + payload: string; + query: string; + }, + mfmTag: { + payload: string; + query: string; + }, + mfmParam: { + payload: string; + query: { + tag: string; + params: string[]; + }; + }, +}; + const lib = emojilist.filter(x => x.category !== 'flags'); -const emojiDb = computed(() => { +const unicodeEmojiDB = computed(() => { //#region Unicode Emoji const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : prefer.r.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; @@ -87,6 +119,12 @@ const emojiDb = computed(() => { } unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length); + + return unicodeEmojiDB; +}); + +const emojiDb = computed(() => { + //#region Unicode Emoji //#endregion //#region Custom Emoji @@ -114,7 +152,7 @@ const emojiDb = computed(() => { customEmojiDB.sort((a, b) => a.name.length - b.name.length); //#endregion - return markRaw([...customEmojiDB, ...unicodeEmojiDB]); + return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]); }); export default { @@ -123,18 +161,23 @@ export default { }; - + + diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index a99ab3bd09..d79ecc7302 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only - -
              {{ code }}
              + +
              {{ code }}