diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 940b095fe..292174629 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -95,6 +95,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForTimelines: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index 086a6ca8f..0e4f2f5a1 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -30,7 +30,7 @@ url: https://example.tld/ # The port that your Misskey server should listen on. port: 3000 -# You can also use UNIX domain socket. +# You can also use UNIX domain socket. # socket: /path/to/misskey.sock # chmodSocket: '777' @@ -60,17 +60,17 @@ dbReplications: false # You can configure any number of replicas here #dbSlaves: # - -# host: -# port: -# db: -# user: -# pass: +# host: +# port: +# db: +# user: +# pass: # - -# host: -# port: -# db: -# user: -# pass: +# host: +# port: +# db: +# user: +# pass: # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── @@ -105,6 +105,16 @@ redis: # # You can specify more ioredis options... # #username: example-username +#redisForTimelines: +# host: localhost +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 +# # You can specify more ioredis options... +# #username: example-username + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── @@ -206,3 +216,6 @@ signToActivityPubGet: true # Upload or download file size limits (bytes) #maxFileSize: 262144000 + +# PID File of master process +#pidFile: /tmp/misskey.pid diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 5dcd41599..3d57d1245 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -95,6 +95,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForTimelines: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index c71157265..a761b79b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,18 +12,60 @@ --> -## next +## 2023.10.0 +### NOTE +- muted_noteテーブルは使われなくなったため手動で削除を行ってください。 + +### Changes +- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました +- API: notes/global-timeline は現在常に `[]` を返します ### General -- Enhance: タイムラインからRenoteを除外するオプションを追加 -- Enhance: ユーザーページのノート一覧でRenoteを除外できるように +- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました +- Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました +- Enhance: ソフトワードミュートとハードワードミュートは統合されました ### Client +- Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に +- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 + +### Server +- Enhance: タイムライン取得時のパフォーマンスを改善 + +## 2023.9.3 +### General +- Enhance: ノートの翻訳機能の利用可否をロールで設定可能に + +### Client +- Enhance: AiScriptでホストのアドレスを参照する定数`SERVER_URL`を追加 - Enhance: モデレーションログ機能の強化 +- Enhance: ローカリゼーションの更新 + +### Server +- Fix: Redisに古いバージョンのキャッシュが残っている場合、キャッシュが消えるまでの間通知が届かなくなる問題を修正 +- Fix: 後方互換性の修正 + +## 2023.9.2 + +### General +- Feat: ノートの編集をできるように + - ロールで編集可否を設定可能 +- Feat: 通知を種類ごとに 全員から受け取る/フォロー中のユーザーのみ受け取る/フォロワーのみ受け取る/相互のみ受け取る/指定したリストのメンバーのみ受け取る/受け取らない から選べるように +- Enhance: タイムラインからRenoteを除外するオプションを追加 +- Enhance: ユーザーページのノート一覧でRenoteを除外できるように +- Enhance: タイムラインでファイルが添付されたノートのみ表示するオプションを追加 +- Enhance: モデレーションログ機能の強化 +- Enhance: 依存関係の更新 +- Enhance: ローカリゼーションの更新 + +### Client - Enhance: Plugin:register_post_form_actionを用いてCWを取得・変更できるように +- Enhance: admin/ad/listにて掲載中の広告が絞り込めるように +- Enhance: AiScriptにリモートサーバーのAPIを叩く用の関数を追加(`Mk:apiExternal`) ### Server - Enhance: MasterプロセスのPIDを書き出せるように +- Enhance: admin/ad/createにてレスポンス200、設定した広告情報を返すように ## 2023.9.1 diff --git a/chart/files/default.yml b/chart/files/default.yml index 90b574b99..87b2f677e 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -116,6 +116,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForTimelines: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 751cc064a..762f033b1 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -2039,3 +2039,4 @@ _webhookSettings: _moderationLogTypes: suspend: "Zmrazit" resetPassword: "Resetovat heslo" + createInvitation: "Vygenerovat pozvánku" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 988e7854d..b474f4dff 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -1120,6 +1120,12 @@ notifyNotes: "Über neue Notizen benachrichtigen" unnotifyNotes: "Nicht über neue Notizen benachrichtigen" authentication: "Authentifikation" authenticationRequiredToContinue: "Bitte authentifiziere dich, um fortzufahren" +dateAndTime: "Zeit" +showRenotes: "Renotes anzeigen" +edited: "Bearbeitet" +notificationRecieveConfig: "Benachrichtigungseinstellungen" +mutualFollow: "Gegenseitig gefolgt" +fileAttachedOnly: "Nur Notizen mit Dateien" _announcement: forExistingUsers: "Nur für existierende Nutzer" forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt." @@ -1450,6 +1456,7 @@ _role: gtlAvailable: "Kann auf die globale Chronik zugreifen" ltlAvailable: "Kann auf die lokale Chronik zugreifen" canPublicNote: "Kann öffentliche Notizen erstellen" + canEditNote: "Notizbearbeitung" canInvite: "Erstellung von Einladungscodes für diese Instanz" inviteLimit: "Maximalanzahl an Einladungen" inviteLimitCycle: "Zyklus des Einladungslimits" @@ -2101,6 +2108,8 @@ _webhookSettings: reaction: "Wenn du eine Reaktion erhältst" mention: "Wenn du erwähnt wirst" _moderationLogTypes: + createRole: "Rolle erstellt" + deleteRole: "Rolle gelöscht" updateRole: "Rolle aktualisiert" assignRole: "Zu Rolle zugewiesen" unassignRole: "Aus Rolle entfernt" @@ -2124,3 +2133,8 @@ _moderationLogTypes: unsuspendRemoteInstance: "Fremde Instanz entsperrt" markSensitiveDriveFile: "Datei als sensitiv markiert" unmarkSensitiveDriveFile: "Datei als nicht sensitiv markiert" + resolveAbuseReport: "Meldung bearbeitet" + createInvitation: "Einladung erstellt" + createAd: "Werbung erstellt" + deleteAd: "Werbung gelöscht" + updateAd: "Werbung aktualisiert" diff --git a/locales/en-US.yml b/locales/en-US.yml index b7c279ac0..24fece3e4 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1120,7 +1120,12 @@ notifyNotes: "Notify about new notes" unnotifyNotes: "Stop notifying about new notes" authentication: "Authentication" authenticationRequiredToContinue: "Please authenticate to continue" -showRenotes: "Include renotes" +dateAndTime: "Timestamp" +showRenotes: "Show renotes" +edited: "Edited" +notificationRecieveConfig: "Notification Settings" +mutualFollow: "Mutual follow" +fileAttachedOnly: "Only notes with files" _announcement: forExistingUsers: "Existing users only" forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it." @@ -1451,6 +1456,7 @@ _role: gtlAvailable: "Can view the global timeline" ltlAvailable: "Can view the local timeline" canPublicNote: "Can send public notes" + canEditNote: "Note editing" canInvite: "Can create instance invite codes" inviteLimit: "Invite limit" inviteLimitCycle: "Invite limit cooldown" @@ -2102,6 +2108,8 @@ _webhookSettings: reaction: "When receiving a reaction" mention: "When being mentioned" _moderationLogTypes: + createRole: "Role created" + deleteRole: "Role deleted" updateRole: "Role updated" assignRole: "Assigned to role" unassignRole: "Removed from role" @@ -2125,3 +2133,8 @@ _moderationLogTypes: unsuspendRemoteInstance: "Remote instance unsuspended" markSensitiveDriveFile: "File marked as sensitive" unmarkSensitiveDriveFile: "File unmarked as sensitive" + resolveAbuseReport: "Report resolved" + createInvitation: "Invite generated" + createAd: "Ad created" + deleteAd: "Ad deleted" + updateAd: "Ad updated" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 1f84a0afb..d663bd829 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -418,6 +418,7 @@ moderator: "Moderador" moderation: "Moderación" moderationNote: "Nota de moderación" addModerationNote: "Añadir nota de moderación" +moderationLogs: "Log de moderación" nUsersMentioned: "{n} usuarios mencionados" securityKeyAndPasskey: "Clave de seguridad / clave de paso" securityKey: "Clave de seguridad" @@ -710,6 +711,7 @@ lockedAccountInfo: "A menos que configures la visibilidad de tus notas como \"S alwaysMarkSensitive: "Marcar los medios de comunicación como contenido sensible por defecto" loadRawImages: "Cargar las imágenes originales en lugar de mostrar las miniaturas" disableShowingAnimatedImages: "No reproducir imágenes animadas" +highlightSensitiveMedia: "Resaltar medios marcados como sensibles" verificationEmailSent: "Se le ha enviado un correo electrónico de confirmación. Por favor, acceda al enlace proporcionado en el correo electrónico para completar la configuración." notSet: "Sin especificar" emailVerified: "Su dirección de correo electrónico ha sido verificada." @@ -1109,6 +1111,16 @@ youHaveUnreadAnnouncements: "Hay anuncios sin leer" useSecurityKey: "Por favor, sigue las instrucciones de tu dispositivo o navegador para usar tu clave de seguridad o tu clave de paso." replies: "Responder" renotes: "Renotar" +loadReplies: "Ver respuestas" +loadConversation: "Ver conversación" +pinnedList: "Lista fijada" +keepScreenOn: "Mantener pantalla encendida" +verifiedLink: "Propiedad del enlace verificada" +notifyNotes: "Notificar nuevas notas" +unnotifyNotes: "Dejar de notificar nuevas notas" +authentication: "Autenticación" +authenticationRequiredToContinue: "Por favor, autentifícate para continuar" +dateAndTime: "Fecha y hora" _announcement: forExistingUsers: "Solo para usuarios registrados" forExistingUsersDescription: "Este anuncio solo se mostrará a aquellos usuarios registrados en el momento de su publicación. Si se deshabilita esta opción, aquellos usuarios que se registren tras su publicación también lo verán." @@ -1137,7 +1149,13 @@ _serverRules: description: "Un conjunto de reglas que serán mostradas antes del registro. Configurar un sumario de términos de servicio es recomendado." _serverSettings: iconUrl: "URL del ícono" + appIconDescription: "Indica el icono que se va a usar cuando {host} se muestre como una app." + appIconUsageExample: "Por ejemplo, como PWA o cuando se muestre como un marcador en la pantalla inicial del dispositivo" + appIconStyleRecommendation: "Como el icono puede ser recortado como un cuadrado o un círculo, se recomienda un icono con un margen coloreado alrededor del contenido." + appIconResolutionMustBe: "La resolución mínima es {resolution}." manifestJsonOverride: "Sobreescribir manifest.json" + shortName: "Nombre corto" + shortNameDescription: "Forma corta del nombre de la instancia que puede mostrarse si el nombre completo es demasiado largo." _accountMigration: moveFrom: "Trasladar de otra cuenta a ésta" moveFromSub: "Crear un alias para otra cuenta." @@ -1784,6 +1802,7 @@ _antennaSources: homeTimeline: "Notas de los usuarios que sigues" users: "Notas de un usuario o varios" userList: "Notas de los usuarios de una lista" + userBlacklist: "Todas las notas excepto aquellas de uno o más usuarios especificados" _weekday: sunday: "Domingo" monday: "Lunes" @@ -1883,6 +1902,7 @@ _profile: metadataContent: "Contenido" changeAvatar: "Cambiar avatar" changeBanner: "Cambiar banner" + verifiedLinkDescription: "Introduciendo una URL que contiene un enlace a tu perfil, se puede mostrar un icono de verificación de propiedad al lado del campo." _exportOrImport: allNotes: "Todas las notas" favoritedNotes: "Notas favoritas" @@ -2001,6 +2021,7 @@ _notification: youReceivedFollowRequest: "Has mandado una solicitud de seguimiento" yourFollowRequestAccepted: "Tu solicitud de seguimiento fue aceptada" pollEnded: "Estan disponibles los resultados de la encuesta" + newNote: "Nueva nota" unreadAntennaNote: "Antena {name}" emptyPushNotificationMessage: "Se han actualizado las notificaciones push" achievementEarned: "Logro desbloqueado" @@ -2010,6 +2031,7 @@ _notification: notificationWillBeDisplayedLikeThis: "Las notificaciones tendrán este aspecto" _types: all: "Todo" + note: "Nuevas notas" follow: "Siguiendo" mention: "Menciones" reply: "Respuestas" @@ -2080,5 +2102,30 @@ _webhookSettings: reaction: "Cuando se recibe una reacción" mention: "Cuando hay una mención" _moderationLogTypes: + createRole: "Rol creado" + deleteRole: "Rol eliminado" + updateRole: "Rol actualizado" + assignRole: "Rol asignado" + unassignRole: "Rol retirado" suspend: "Suspender" + unsuspend: "Suspensión retirada" + addCustomEmoji: "Añadido emoji personalizado" + updateCustomEmoji: "Emoji personalizado actualizado" + deleteCustomEmoji: "Emoji personalizado eliminado" + updateServerSettings: "Ajustes de servidor actualizados" + updateUserNote: "Nota de moderación actualizada" + deleteDriveFile: "Archivo eliminado" + deleteNote: "Nota eliminada" + createGlobalAnnouncement: "Anuncio global creado" + createUserAnnouncement: "Anuncio de usuario creado" + updateGlobalAnnouncement: "Anuncio global actualizado" + updateUserAnnouncement: "Anuncio de usuario actualizado" + deleteGlobalAnnouncement: "Anuncio global eliminado" + deleteUserAnnouncement: "Anuncio de usuario eliminado" resetPassword: "Resetear contraseña" + suspendRemoteInstance: "Instancia remota suspendida" + unsuspendRemoteInstance: "Suspensión de instancia remota retirada" + markSensitiveDriveFile: "Archivo marcado como sensible" + unmarkSensitiveDriveFile: "Archivo marcado como no sensible" + resolveAbuseReport: "Reporte resuelto" + createInvitation: "Generar invitación" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 56e7f9e4d..0e067c538 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -1100,6 +1100,7 @@ currentAnnouncements: "Pengumuman Saat Ini" pastAnnouncements: "Pengumuman Terdahulu" replies: "Balas" renotes: "Renote" +dateAndTime: "Tanggal dan Waktu" _initialAccountSetting: accountCreated: "Akun kamu telah sukses dibuat!" letsStartAccountSetup: "Untuk pemula, ayo atur profilmu dulu." @@ -2044,3 +2045,4 @@ _webhookSettings: _moderationLogTypes: suspend: "Tangguhkan" resetPassword: "Atur ulang kata sandi" + createInvitation: "Buat kode undangan" diff --git a/locales/index.d.ts b/locales/index.d.ts index eb2793c71..418e1c67f 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1125,6 +1125,12 @@ export interface Locale { "authenticationRequiredToContinue": string; "dateAndTime": string; "showRenotes": string; + "edited": string; + "notificationRecieveConfig": string; + "mutualFollow": string; + "fileAttachedOnly": string; + "showRepliesToOthersInTimeline": string; + "hideRepliesToOthersInTimeline": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -1538,6 +1544,7 @@ export interface Locale { "gtlAvailable": string; "ltlAvailable": string; "canPublicNote": string; + "canEditNote": string; "canInvite": string; "inviteLimit": string; "inviteLimitCycle": string; @@ -1557,6 +1564,7 @@ export interface Locale { "descriptionOfRateLimitFactor": string; "canHideAds": string; "canSearchNotes": string; + "canUseTranslator": string; }; "_condition": { "isLocal": string; @@ -1713,11 +1721,6 @@ export interface Locale { "muteWords": string; "muteWordsDescription": string; "muteWordsDescription2": string; - "softDescription": string; - "hardDescription": string; - "soft": string; - "hard": string; - "mutedNotes": string; }; "_instanceMute": { "instanceMuteDescription": string; @@ -2279,6 +2282,9 @@ export interface Locale { "unmarkSensitiveDriveFile": string; "resolveAbuseReport": string; "createInvitation": string; + "createAd": string; + "deleteAd": string; + "updateAd": string; }; } declare const locales: { diff --git a/locales/it-IT.yml b/locales/it-IT.yml index e99a41b75..83b63e15d 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -130,8 +130,8 @@ unmarkAsSensitive: "Non segnare come esplicito " enterFileName: "Nome del file" mute: "Silenzia" unmute: "Riattiva l'audio" -renoteMute: "Silenzia i Rinota" -renoteUnmute: "Non silenziare i Rinota" +renoteMute: "Silenzia le Rinota" +renoteUnmute: "Non silenziare le Rinota" block: "Blocca" unblock: "Sblocca" suspend: "Sospensione" @@ -991,7 +991,7 @@ thisPostMayBeAnnoying: "Questa nota potrebbe essere offensiva" thisPostMayBeAnnoyingHome: "Pubblica sulla timeline principale" thisPostMayBeAnnoyingCancel: "Annulla" thisPostMayBeAnnoyingIgnore: "Pubblica lo stesso" -collapseRenotes: "Comprimi i Rinota già letti" +collapseRenotes: "Comprimi le Rinota già viste" internalServerError: "Errore interno del server" internalServerErrorDescription: "Si è verificato un errore imprevisto all'interno del server" copyErrorInfo: "Copia le informazioni sull'errore" @@ -1120,6 +1120,8 @@ notifyNotes: "Notifica nuove Note" unnotifyNotes: "Interrompi le notifiche di nuove Note" authentication: "Autenticazione" authenticationRequiredToContinue: "Per procedere, è richiesta l'autenticazione" +dateAndTime: "Data e Ora" +showRenotes: "Leggi le Rinota" _announcement: forExistingUsers: "Solo ai profili attuali" forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio." @@ -2101,6 +2103,8 @@ _webhookSettings: reaction: "Quando ricevo una reazione" mention: "Quando mi menzionano" _moderationLogTypes: + createRole: "Ruolo creato" + deleteRole: "Ruolo eliminato" updateRole: "Ruolo aggiornato" assignRole: "Ruolo assegnato" unassignRole: "Ruolo disassegnato" @@ -2124,3 +2128,5 @@ _moderationLogTypes: unsuspendRemoteInstance: "Istanza remota riattivata" markSensitiveDriveFile: "File nel Drive segnato come esplicito" unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito" + resolveAbuseReport: "Segnalazione risolta" + createInvitation: "Genera codice di invito" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 637d580d6..80e4466a7 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1122,6 +1122,12 @@ authentication: "認証" authenticationRequiredToContinue: "続けるには認証を行ってください" dateAndTime: "日時" showRenotes: "リノートを表示" +edited: "編集済み" +notificationRecieveConfig: "通知の受信設定" +mutualFollow: "相互フォロー" +fileAttachedOnly: "ファイル付きのみ" +showRepliesToOthersInTimeline: "TLに他の人への返信を含める" +hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -1459,6 +1465,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" + canEditNote: "ノートの編集" canInvite: "サーバー招待コードの発行" inviteLimit: "招待コードの作成可能数" inviteLimitCycle: "招待コードの発行間隔" @@ -1477,7 +1484,8 @@ _role: rateLimitFactor: "レートリミット" descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。" canHideAds: "広告の非表示" - canSearchNotes: "ノート検索の利用可否" + canSearchNotes: "ノート検索の利用" + canUseTranslator: "翻訳機能の利用" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -1630,11 +1638,6 @@ _wordMute: muteWords: "ミュートするワード" muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" - softDescription: "指定した条件のノートをタイムラインから隠します。" - hardDescription: "指定した条件のノートをタイムラインに追加しないようにします。追加されなかったノートは、条件を変更しても除外されたままになります。" - soft: "ソフト" - hard: "ハード" - mutedNotes: "ミュートされたノート" _instanceMute: instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのノートとRenoteをミュートします。" @@ -2192,3 +2195,6 @@ _moderationLogTypes: unmarkSensitiveDriveFile: "ファイルをセンシティブ解除" resolveAbuseReport: "通報を解決" createInvitation: "招待コードを作成" + createAd: "広告を作成" + deleteAd: "広告を削除" + updateAd: "広告を更新" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index efbb0ff5f..bf945088f 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -1105,6 +1105,10 @@ youHaveUnreadAnnouncements: "あんたまだこのお知らせ読んどらんや useSecurityKey: "ブラウザまたはデバイスの言う通りに、セキュリティキーまたはパスキーを使ってや。" replies: "返事" renotes: "Renote" +loadReplies: "返信を見るで" +loadConversation: "会話を見るで" +verifiedLink: "このリンク先の所有者であることが確認されたで。" +authenticationRequiredToContinue: "続けるには認証をやってや。" _announcement: forExistingUsers: "もうおるユーザーのみ" forExistingUsersDescription: "有効にすると、このお知らせ作成時点でおるユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。" @@ -1133,6 +1137,11 @@ _serverRules: description: "新規登録前に見せる、サーバーの簡潔なルールを設定すんで。内容は使うための決め事の要約とすることを推奨するわ。" _serverSettings: iconUrl: "アイコン画像のURL" + appIconDescription: "{host}がアプリとして表示してるんやつをアイコンを指定すんで。" + appIconUsageExample: "PWAや、スマートフォンのホーム画面にブックマークとして追加された時など" + appIconStyleRecommendation: "円形もしくは角丸にクロップされる場合があるさかいに、塗り潰された余白のある背景があるものが推奨されるで。" + appIconResolutionMustBe: "解像度は必ず{resolution}である必要があるで。" + shortNameDescription: "サーバーの名前が長い時に、代わりに表示することのできるあだ名。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに引っ越す" moveFromSub: "別のアカウントへエイリアスを作る" @@ -1703,6 +1712,7 @@ _2fa: step2Click: "QRコードをクリックすると、今使とる端末に入っとる認証アプリとかキーリングに登録できるで。" step3Title: "確認コードを入れてーや" step3: "アプリに表示されているトークンを入力して終わりや。" + setupCompleted: "設定が完了したで。" step4: "これからログインするときも、同じようにトークンを入力するんやで" securityKeyNotSupported: "今使とるブラウザはセキュリティキーに対応してへんのやってさ。" registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するんやったら、まず認証アプリを設定してーな。" @@ -1717,6 +1727,10 @@ _2fa: renewTOTPConfirm: "今までの認証アプリの確認コードは使えんくなるけどええか?" renewTOTPOk: "もっかい設定する" renewTOTPCancel: "やめとく" + checkBackupCodesBeforeCloseThisWizard: "このウィザードを閉じる前に、したのバックアップコードを確認しいや。" + backupCodesDescription: "認証アプリが使用できんなった場合、以下のバックアップコードを使ってアカウントにアクセスできるで。これらのコードは必ず安全な場所に置いときや。各コードは一回だけ使用できるで。" + backupCodeUsedWarning: "バックアップコードが使用されたで。認証アプリが使えなくなってるん場合、なるべく早く認証アプリを再設定しや。" + backupCodesExhaustedWarning: "バックアップコードが全て使用されたで。認証アプリを利用できん場合、これ以上アカウントにアクセスできなくなるで。認証アプリを再登録しや。" _permissions: "read:account": "アカウントの情報を見るで" "write:account": "アカウントの情報を変更するで" @@ -1989,6 +2003,9 @@ _notification: unreadAntennaNote: "アンテナ {name}" emptyPushNotificationMessage: "プッシュ通知の更新をしといたで" achievementEarned: "実績を獲得しとるで" + checkNotificationBehavior: "通知の表示を確かめるで" + sendTestNotification: "テスト通知を送信するで" + notificationWillBeDisplayedLikeThis: "通知はこのように表示されるで" _types: all: "すべて" follow: "フォロー" @@ -2024,6 +2041,7 @@ _deck: introduction2: "画面の右にある + を押して、いつでもカラムを追加できるで。" widgetsIntroduction: "カラムのメニューから、「ウィジェットの編集」を選んでウィジェットを追加してなー" useSimpleUiForNonRootPages: "非ルートページは簡易UIで表示" + usedAsMinWidthWhenFlexible: "「幅を自動調整」が有効の場合、これが幅の最小値となるで" _columns: main: "メイン" widgets: "ウィジェット" @@ -2061,3 +2079,4 @@ _webhookSettings: _moderationLogTypes: suspend: "凍結" resetPassword: "パスワードをリセット" + createInvitation: "招待コードを作成" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 0fcb39ebc..af7afb2c3 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -416,6 +416,9 @@ totp: "인증 앱" totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력" moderator: "모더레이터" moderation: "모더레이션" +moderationNote: "모더레이션 노트" +addModerationNote: "모더레이션 노트 추가하기" +moderationLogs: "모더레이션 로그" nUsersMentioned: "{n}명이 언급함" securityKeyAndPasskey: "보안 키 또는 패스 키" securityKey: "보안 키" @@ -1107,6 +1110,18 @@ youHaveUnreadAnnouncements: "읽지 않은 공지사항이 있습니다." useSecurityKey: "브라우저 또는 기기의 안내에 따라 보안 키 또는 패스키를 사용해 주십시오." replies: "답글" renotes: "리노트" +loadReplies: "답글 보기" +loadConversation: "대화 보기" +pinnedList: "고정해놓은 리스트" +keepScreenOn: "기기 화면을 항상 켜기" +verifiedLink: "이 링크의 소유자임이 확인되었습니다." +notifyNotes: "새 노트 알림 켜기" +unnotifyNotes: "새 노트 알림 끄기" +authentication: "인증" +showRenotes: "리노트 표시" +edited: "수정됨" +notificationRecieveConfig: "알림 설정" +mutualFollow: "맞팔로우" _announcement: forExistingUsers: "기존 유저에게만 알림" forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시합니다. 비활성화하면 게시 후에 가입한 유저에게도 표시합니다." @@ -1135,6 +1150,12 @@ _serverRules: description: "회원 가입 이전에 간단하게 표시할 서버 규칙입니다. 이용 약관의 요약으로 구성하는 것을 추천합니다." _serverSettings: iconUrl: "아이콘 URL" + appIconUsageExample: "예를 들어, PWA나 스마트폰 홈 화면에 북마크로 추가되었을 때 등" + appIconStyleRecommendation: "아이콘이 원형 또는 둥근 사각형으로 잘리는 경우가 있으므로, 가장자리 여백이 충분한 사진을 사용하는 것을 추천합니다." + appIconResolutionMustBe: "해상도는 반드시 {resolution} 이어야 합니다." + manifestJsonOverride: "manifest.json 오버라이드" + shortName: "약칭" + shortNameDescription: "서버의 정식 명칭이 긴 경우에, 대신에 표시할 수 있는 약칭이나 통칭." _accountMigration: moveFrom: "다른 계정에서 이 계정으로 이사" moveFromSub: "다른 계정에 대한 별칭을 생성" @@ -2076,3 +2097,4 @@ _webhookSettings: _moderationLogTypes: suspend: "정지" resetPassword: "비밀번호 재설정" + createInvitation: "초대 코드 생성" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index c252ce93f..c2adcf8ec 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -416,6 +416,9 @@ totp: "แอป Authenticator" totpDescription: "ใช้แอปยืนยันตัวตนเพื่อป้อนรหัสผ่านแบบใช้ครั้งเดียว" moderator: "ผู้ควบคุม" moderation: "การกลั่นกรอง" +moderationNote: "โน้ตการกลั่นกรอง" +addModerationNote: "เพิ่มโน้ตการกลั่นกรอง" +moderationLogs: "บันทึกการกลั่นกรอง" nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} รายนี้" securityKeyAndPasskey: "ความปลอดภัยและรหัสผ่าน" securityKey: "กุญแจความปลอดภัย" @@ -708,6 +711,7 @@ lockedAccountInfo: "เว้นแต่ว่าคุณจะต้องต alwaysMarkSensitive: "ทำเครื่องหมายเป็น NSFW เป็นค่าเริ่มต้น" loadRawImages: "โหลดภาพต้นฉบับแทนการแสดงภาพขนาดย่อ" disableShowingAnimatedImages: "ไม่ต้องเล่นภาพเคลื่อนไหว" +highlightSensitiveMedia: "ไฮไลท์สื่อที่ละเอียดอ่อน" verificationEmailSent: "ส่งอีเมลยืนยันแล้วนะ ได้โปรดกรุณาไปที่ลิงก์ที่รวมไว้เพื่อทำการตรวจสอบให้เสร็จสิ้น" notSet: "ไม่ได้ตั้งค่า" emailVerified: "อีเมลได้รับการยืนยันแล้ว" @@ -1022,6 +1026,7 @@ retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการ enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล" enableChartsForFederatedInstances: "สร้างแผนภูมิข้อมูลอินสแตนซ์ระยะไกล" showClipButtonInNoteFooter: "เพิ่ม \"คลิป\" เพื่อบันทึกเมนูการทำงาน" +reactionsDisplaySize: "รีแอคชั่นแสดงผลขนาด" noteIdOrUrl: "โน้ต ID หรือ URL" video: "วีดีโอ" videos: "วีดีโอ" @@ -1100,11 +1105,26 @@ iHaveReadXCarefullyAndAgree: "ฉันได้อ่านข้อควา dialog: "ไดอะล็อก" icon: "ไอคอน" forYou: "สำหรับคุณ" +currentAnnouncements: "ประกาศในปัจจุบัน" +pastAnnouncements: "ประกาศที่ผ่านมา" +youHaveUnreadAnnouncements: "มีการประกาศที่ยังไม่ได้อ่าน" replies: "ตอบกลับ" renotes: "รีโน้ต" loadReplies: "แสดงการตอบกลับ" loadConversation: "แสดงบทสนทนา" +pinnedList: "รายการที่ปักหมุดไว้แล้ว" +keepScreenOn: "เปิดหน้าจอไว้" +notifyNotes: "แจ้งเตือนเกี่ยวกับโพสต์ใหม่" +unnotifyNotes: "หยุดการแจ้งเตือนเกี่ยวกับโน้ตใหม่" +authentication: "การตรวจสอบสิทธิ์" +dateAndTime: "เวลาประทับ" +showRenotes: "แสดงรีโน้ต" +edited: "แก้ไขแล้ว" +notificationRecieveConfig: "การตั้งค่าการแจ้งเตือน" +mutualFollow: "ติดตามซึ่งกันและกัน" +fileAttachedOnly: "เฉพาะโน้ตที่มีไฟล์เท่านั้น" _announcement: + forExistingUsers: "ผู้ใช้งานที่มีอยู่เท่านั้น" forExistingUsersDescription: "การประกาศนี้จะแสดงต่อผู้ใช้ที่มีอยู่ ณ จุดที่เผยแพร่นั้นๆถ้าหากเปิดใช้งาน ถ้าหากปิดใช้งานผู้ที่กำลังสมัครใหม่หลังจากโพสต์แล้วนั้นก็จะเห็นเช่นกัน" needConfirmationToReadDescription: "ข้อความแจ้งแยก ถ้าหากต้องการเพื่อยืนยันว่ากำลังทำเครื่องหมายประกาศนี้ว่าอ่านแล้วจะแสดงขึ้นถ้าหากเปิดใช้งาน การประกาศนั้นจะไม่รวมอยู่ในฟังก์ชั่นว่า \"ทำเครื่องหมายทั้งหมดว่าอ่านแล้ว\"" end: "ประกาศเก็บถาวร" @@ -1130,6 +1150,7 @@ _serverRules: description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ" _serverSettings: iconUrl: "ไอคอน URL" + shortName: "ชื่อย่อ" _accountMigration: moveFrom: "ย้ายข้อมูลบัญชีอื่นไปยังอีกบัญชีนี้หนึ่ง" moveFromSub: "สร้างนามแฝงไปยังบัญชีอื่น" @@ -1424,6 +1445,7 @@ _role: gtlAvailable: "การดูไทม์ไลน์ทั่วโลก" ltlAvailable: "การดูไทม์ไลน์ในท้องถิ่น" canPublicNote: "สามารถส่งโน้ตสาธารณะ" + canEditNote: "กำลังแก้ไขโน้ต" canInvite: "สร้างรหัสเชิญอินสแตนซ์" inviteLimit: "จำกัดการเชิญ" inviteLimitCycle: "จำกัดการเชิญไว้คูลดาวน์" @@ -1987,6 +2009,7 @@ _notification: youReceivedFollowRequest: "คุณมีคำขอติดตามใหม่น่ะ" yourFollowRequestAccepted: "คำขอติดตามของคุณได้รับการยอมรับแล้วน่ะ" pollEnded: "โพลสำรวจความคิดเห็นผลลัพธ์มีพร้อมใช้งาน" + newNote: "โพสต์ใหม่" unreadAntennaNote: "เสาอากาศ {name}" emptyPushNotificationMessage: "การแจ้งเตือนแบบพุชได้รับการอัพเดทแล้ว" achievementEarned: "รับความสำเร็จ" @@ -1996,6 +2019,7 @@ _notification: notificationWillBeDisplayedLikeThis: "การแจ้งเตือนมีลักษณะแบบนี้" _types: all: "ทั้งหมด" + note: "โน้ตใหม่" follow: "กำลังติดตาม" mention: "กล่าวถึง" reply: "ตอบกลับ" @@ -2066,5 +2090,23 @@ _webhookSettings: reaction: "เมื่อได้รับรีแอคชั่น" mention: "เมื่อกำลังถูกกล่าวถึง" _moderationLogTypes: + createRole: "สร้างบทบาทแล้ว" + deleteRole: "ลบบทบาทแล้ว" + updateRole: "อัปเดตบทบาทแล้ว" + assignRole: "ได้รับมอบหมายบทบาท" + unassignRole: "ถอดออกจากบทบาทแล้ว" suspend: "ถูกระงับ" + unsuspend: "เลิกถูกระงับ" + addCustomEmoji: "เพิ่มอีโมจิที่กำหนดเองแล้ว" + updateCustomEmoji: "อัปเดตอีโมจิที่กำหนดเองแล้ว" + deleteCustomEmoji: "ลบอีโมจิที่กำหนดเองออกแล้ว" + updateServerSettings: "อัปเดตการตั้งค่าเซิร์ฟเวอร์แล้ว" + updateUserNote: "อัปเดตโน้ตการกลั่นกรองแล้ว" + deleteDriveFile: "ลบไฟล์ออกแล้ว" + deleteNote: "ลบโน้ตออกแล้ว" resetPassword: "รีเซ็ตรหัสผ่าน" + resolveAbuseReport: "รายงานได้รับการแก้ไขแล้ว" + createInvitation: "สร้างคำเชิญ" + createAd: "สร้างโฆษณาแล้ว" + deleteAd: "ลบโฆษณาออกแล้ว" + updateAd: "อัปเดตโฆษณาแล้ว" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 27f95eb37..a04697e48 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1120,6 +1120,12 @@ notifyNotes: "打开发帖通知" unnotifyNotes: "关闭发帖通知" authentication: "验证" authenticationRequiredToContinue: "要继续,请先进行验证" +dateAndTime: "日期和时间" +showRenotes: "显示转帖" +edited: "已编辑" +notificationRecieveConfig: "通知接收设置" +mutualFollow: "互相关注" +fileAttachedOnly: "仅限媒体" _announcement: forExistingUsers: "仅限现有用户" forExistingUsersDescription: "若启用,该公告将仅对创建此公告时存在的用户可见。 如果禁用,则在创建此公告后注册的用户也可以看到该公告。" @@ -1450,6 +1456,7 @@ _role: gtlAvailable: "查看全局时间线" ltlAvailable: "查看本地时间线" canPublicNote: "允许公开发帖" + canEditNote: "编辑帖子" canInvite: "发放服务器邀请码" inviteLimit: "可发行邀请码的数量" inviteLimitCycle: "邀请码的发行间隔" @@ -2101,6 +2108,8 @@ _webhookSettings: reaction: "被回应时" mention: "被提及时" _moderationLogTypes: + createRole: "创建角色" + deleteRole: "删除角色" updateRole: "更新角色" assignRole: "分配角色" unassignRole: "取消分配角色" @@ -2122,3 +2131,8 @@ _moderationLogTypes: resetPassword: "重置密码" markSensitiveDriveFile: "标记网盘文件为敏感媒体" unmarkSensitiveDriveFile: "取消标记网盘文件为敏感媒体" + resolveAbuseReport: "处理举报" + createInvitation: "发行邀请码" + createAd: "创建了广告" + deleteAd: "删除了广告" + updateAd: "更新了广告" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 7cea24409..c0bf1f7d1 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -15,7 +15,7 @@ gotIt: "知道了" cancel: "取消" noThankYou: "現在不要" enterUsername: "輸入使用者名稱" -renotedBy: "{user} 轉發" +renotedBy: "{user} 轉發了" noNotes: "無貼文" noNotifications: "沒有通知" instance: "伺服器" @@ -56,7 +56,7 @@ copyRSS: "複製RSS" copyUsername: "複製使用者名稱" copyUserId: "複製使用者 ID" copyNoteId: "複製貼文 ID" -copyFileId: "複製檔案ID" +copyFileId: "複製檔案 ID" copyFolderId: "複製資料夾ID" copyProfileUrl: "複製個人資料網址" searchUser: "搜尋使用者" @@ -75,9 +75,9 @@ import: "匯入" export: "匯出" files: "檔案" download: "下載" -driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此附件的貼文也會跟著消失。\n" +driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此檔案的貼文也會跟著被刪除。" unfollowConfirm: "確定要取消追隨{name}嗎?" -exportRequested: "已請求匯出。這可能會花一點時間。匯出的檔案將會被放到雲端裡。" +exportRequested: "已請求匯出。這可能會花一點時間。匯出的檔案將會被放到雲端硬碟裡。" importRequested: "已請求匯入。這可能會花一點時間。" lists: "清單" noLists: "你沒有任何清單" @@ -156,16 +156,16 @@ emojiUrl: "表情符號 URL" addEmoji: "新增表情符號" settingGuide: "推薦設定" cacheRemoteFiles: "快取遠端檔案" -cacheRemoteFilesDescription: "啟用此設定後,遠端檔案會被快取在本伺服器的儲存空間中。雖然顯示圖片會變快,但會消耗較多伺服器的儲存空間。至於要快取遠端使用者到什麼程度,是依照角色的雲端硬碟容量而定。當超過這個限制時,從較舊的檔案開始自快取中刪除並改為連結。關閉這個設定時,遠端檔案從一開始就維持連結的方式,但為了產生圖片的縮圖並保護使用者的隱私,建議將 default.yml 的 proxyRemoteFiles 設為 true。" +cacheRemoteFilesDescription: "啟用此設定後,遠端檔案會被快取在本伺服器的儲存空間中。雖然顯示圖片會變快,但會消耗較多伺服器的儲存空間。至於要快取遠端使用者到什麼程度,是依照角色的雲端硬碟容量而定。當超過這個限制時,從較舊的檔案開始自快取中刪除並改為連結。關閉這個設定時,遠端檔案從一開始就維持連結的方式,但建議將 default.yml 的 proxyRemoteFiles 設為 true,以便產生圖片的縮圖並保護使用者的隱私,。" youCanCleanRemoteFilesCache: "按檔案管理的🗑️按鈕,可將快取全部刪除。" cacheRemoteSensitiveFiles: "快取遠端的敏感檔案" cacheRemoteSensitiveFilesDescription: "若停用這個設定,則不會快取遠端的敏感檔案,而是直接連結。" flagAsBot: "此使用者是機器人" -flagAsBotDescription: "標記本帳戶由程式控制,防止其他程式與本帳戶產生無限互動的行為。" +flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整Misskey內部系統將本帳戶識別為機器人" flagAsCat: "此帳戶是一隻貓,喵~~~!!!" flagAsCatDescription: "如果想將本帳戶標示為一隻貓,請開啟此標示" flagShowTimelineReplies: "在時間軸上顯示貼文的回覆" -flagShowTimelineRepliesDescription: "啟用時,時間軸除了顯示使用者的貼文以外,還會顯示使用者對其他貼文的回覆。" +flagShowTimelineRepliesDescription: "啟用後,時間軸除了顯示使用者的貼文以外,還會顯示使用者對其他貼文的回覆。" autoAcceptFollowed: "自動允許來自追隨中使用者的追隨請求" addAccount: "新增帳戶" reloadAccountsList: "更新帳戶清單的資訊" @@ -184,7 +184,7 @@ host: "主機" selectUser: "選取使用者" recipient: "收件人" annotation: "註解" -federation: "聯邦宇宙" +federation: "站台聯邦" instances: "伺服器" registeredAt: "初次觀測" latestRequestReceivedAt: "上次收到的請求" @@ -573,7 +573,7 @@ tokenRevokedDescription: "登入權杖失效,請重新登入。" accountDeleted: "帳戶已被刪除" accountDeletedDescription: "這個帳戶已被刪除。" menu: "選單" -divider: "分割線" +divider: "分隔線" addItem: "新增項目" rearrange: "排序方式" relays: "中繼" @@ -582,7 +582,7 @@ inboxUrl: "收件夾URL" addedRelays: "已加入的中繼" serviceworkerInfo: "您需要啟用推送通知。" deletedNote: "已刪除的貼文" -invisibleNote: "隱藏的貼文" +invisibleNote: "私密的貼文" enableInfiniteScroll: "啟用自動滾動頁面模式" visibility: "可見性" poll: "投票" @@ -1121,6 +1121,9 @@ unnotifyNotes: "關閉貼文通知" authentication: "驗證" authenticationRequiredToContinue: "請於繼續前完成驗證" dateAndTime: "日期與時間" +showRenotes: "顯示轉發貼文" +edited: "已編輯" +mutualFollow: "互相追隨" _announcement: forExistingUsers: "僅限既有的使用者" forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。" @@ -1451,6 +1454,7 @@ _role: gtlAvailable: "瀏覽全域時間軸" ltlAvailable: "瀏覽本地時間軸" canPublicNote: "允許公開貼文" + canEditNote: "允許編輯貼文" canInvite: "發行實例邀請碼" inviteLimit: "可建立邀請碼的數量" inviteLimitCycle: "邀請碼的發放間隔" @@ -1576,8 +1580,8 @@ _displayOfSensitiveMedia: force: "隱藏所有檔案" _instanceTicker: none: "隱藏" - remote: "向遠端使用者顯示" - always: "總是顯示" + remote: "只顯示遠端使用者" + always: "一律顯示" _serverDisconnectedBehavior: reload: "自動重載" dialog: "彈出式警告" @@ -1610,7 +1614,7 @@ _wordMute: mutedNotes: "已靜音的貼文" _instanceMute: instanceMuteDescription: "包括對被靜音實例上的使用者的回覆,被設定的實例上所有貼文及轉發都會被靜音。" - instanceMuteDescription2: "換行以分隔" + instanceMuteDescription2: "設定時以換行進行分隔" title: "將隱藏被設定的實例貼文。" heading: "將實例靜音" _theme: @@ -1663,7 +1667,7 @@ _theme: mentionMe: "提到了我" renote: "轉發貼文" modalBg: "對話框背景" - divider: "分割線" + divider: "分隔線" scrollbarHandle: "捲動條" scrollbarHandleHover: "捲動條(懸浮)" dateLabelFg: "日期標籤文字" @@ -2127,3 +2131,8 @@ _moderationLogTypes: unsuspendRemoteInstance: "解除封鎖遠端伺服器" markSensitiveDriveFile: "標記為敏感檔案" unmarkSensitiveDriveFile: "撤銷標記為敏感檔案" + resolveAbuseReport: "解決檢舉" + createInvitation: "建立邀請碼" + createAd: "建立廣告" + deleteAd: "刪除廣告" + updateAd: "更新廣告" diff --git a/package.json b/package.json index f11c74ccd..547d9d546 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "sharkey", - "version": "2023.9.1.beta3", + "version": "2023.9.1.beta4", "codename": "shonk", "repository": { "type": "git", "url": "https://github.com/transfem-org/sharkey.git" }, - "packageManager": "pnpm@8.7.6", + "packageManager": "pnpm@8.8.0", "workspaces": [ "packages/frontend", "packages/backend", @@ -46,15 +46,15 @@ "execa": "8.0.1", "cssnano": "6.0.1", "js-yaml": "4.1.0", - "postcss": "8.4.30", + "postcss": "8.4.31", "terser": "5.20.0", "typescript": "5.2.2" }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "6.7.2", - "@typescript-eslint/parser": "6.7.2", + "@typescript-eslint/eslint-plugin": "6.7.3", + "@typescript-eslint/parser": "6.7.3", "cross-env": "7.0.3", - "cypress": "13.2.0", + "cypress": "13.3.0", "eslint": "8.50.0", "start-server-and-test": "2.0.1" }, diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs index 6b1afec73..97d777c86 100644 --- a/packages/backend/jest.config.cjs +++ b/packages/backend/jest.config.cjs @@ -216,4 +216,6 @@ module.exports = { maxWorkers: 1, // Make it use worker (that can be killed and restarted) logHeapUsage: true, // To debug when out-of-memory happens on CI workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB) + + maxConcurrency: 32, }; diff --git a/packages/backend/migration/1695944637565-notificationRecieveConfig.js b/packages/backend/migration/1695944637565-notificationRecieveConfig.js new file mode 100644 index 000000000..42d3dce5d --- /dev/null +++ b/packages/backend/migration/1695944637565-notificationRecieveConfig.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NotificationRecieveConfig1695944637565 { + name = 'NotificationRecieveConfig1695944637565' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mutingNotificationTypes"`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "notificationRecieveConfig" jsonb NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "notificationRecieveConfig"`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "mutingNotificationTypes" "public"."user_profile_notificationrecieveconfig_enum" array NOT NULL DEFAULT '{}'`); + } +} diff --git a/packages/backend/migration/1696222183852-withReplies.js b/packages/backend/migration/1696222183852-withReplies.js new file mode 100644 index 000000000..9f65d5f6a --- /dev/null +++ b/packages/backend/migration/1696222183852-withReplies.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class WithReplies1696222183852 { + name = 'WithReplies1696222183852' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "following" ADD "withReplies" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "user_list_joining" ADD "withReplies" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`); + await queryRunner.query(`ALTER TABLE "user_list_joining" DROP COLUMN "withReplies"`); + await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "withReplies"`); + } +} diff --git a/packages/backend/migration/1696323464251-user-list-membership.js b/packages/backend/migration/1696323464251-user-list-membership.js new file mode 100644 index 000000000..7534040c4 --- /dev/null +++ b/packages/backend/migration/1696323464251-user-list-membership.js @@ -0,0 +1,11 @@ +export class UserListMembership1696323464251 { + name = 'UserListMembership1696323464251' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_list_joining" RENAME TO "user_list_membership"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_list_membership" RENAME TO "user_list_joining"`); + } +} diff --git a/packages/backend/migration/1696331570827-hibernation.js b/packages/backend/migration/1696331570827-hibernation.js new file mode 100644 index 000000000..119d35913 --- /dev/null +++ b/packages/backend/migration/1696331570827-hibernation.js @@ -0,0 +1,17 @@ +export class Hibernation1696331570827 { + name = 'Hibernation1696331570827' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_d74d8ab5efa7e3bb82825c0fa2"`); + await queryRunner.query(`ALTER TABLE "user" ADD "isHibernated" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerHibernated" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`); + await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerHibernated"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isHibernated"`); + await queryRunner.query(`CREATE INDEX "IDX_d74d8ab5efa7e3bb82825c0fa2" ON "following" ("followeeId", "followerHost") `); + } +} diff --git a/packages/backend/migration/1696332072038-clean.js b/packages/backend/migration/1696332072038-clean.js new file mode 100644 index 000000000..97dba655f --- /dev/null +++ b/packages/backend/migration/1696332072038-clean.js @@ -0,0 +1,33 @@ +export class Clean1696332072038 { + name = 'Clean1696332072038' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_list_membership" DROP CONSTRAINT "FK_d844bfc6f3f523a05189076efaa"`); + await queryRunner.query(`ALTER TABLE "user_list_membership" DROP CONSTRAINT "FK_605472305f26818cc93d1baaa74"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d844bfc6f3f523a05189076efa"`); + await queryRunner.query(`DROP INDEX "public"."IDX_605472305f26818cc93d1baaa7"`); + await queryRunner.query(`DROP INDEX "public"."IDX_90f7da835e4c10aca6853621e1"`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "preservedUsernames" SET DEFAULT '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }'`); + await queryRunner.query(`COMMENT ON COLUMN "user_list_membership"."createdAt" IS 'The created date of the UserListMembership.'`); + await queryRunner.query(`CREATE INDEX "IDX_021015e6683570ae9f6b0c62be" ON "user_list_membership" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_cddcaf418dc4d392ecfcca842a" ON "user_list_membership" ("userListId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_e4f3094c43f2d665e6030b0337" ON "user_list_membership" ("userId", "userListId") `); + await queryRunner.query(`ALTER TABLE "user_list_membership" ADD CONSTRAINT "FK_021015e6683570ae9f6b0c62bee" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_list_membership" ADD CONSTRAINT "FK_cddcaf418dc4d392ecfcca842a7" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_list_membership" DROP CONSTRAINT "FK_cddcaf418dc4d392ecfcca842a7"`); + await queryRunner.query(`ALTER TABLE "user_list_membership" DROP CONSTRAINT "FK_021015e6683570ae9f6b0c62bee"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e4f3094c43f2d665e6030b0337"`); + await queryRunner.query(`DROP INDEX "public"."IDX_cddcaf418dc4d392ecfcca842a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_021015e6683570ae9f6b0c62be"`); + await queryRunner.query(`COMMENT ON COLUMN "user_list_membership"."createdAt" IS 'The created date of the UserListJoining.'`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "preservedUsernames" SET DEFAULT '{admin,administrator,root,system,maintainer,host,mod,moderator,owner,superuser,staff,auth,i,me,everyone,all,mention,mentions,example,user,users,account,accounts,official,help,helps,support,supports,info,information,informations,announce,announces,announcement,announcements,notice,notification,notifications,dev,developer,developers,tech,misskey}'`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_90f7da835e4c10aca6853621e1" ON "user_list_membership" ("userId", "userListId") `); + await queryRunner.query(`CREATE INDEX "IDX_605472305f26818cc93d1baaa7" ON "user_list_membership" ("userListId") `); + await queryRunner.query(`CREATE INDEX "IDX_d844bfc6f3f523a05189076efa" ON "user_list_membership" ("userId") `); + await queryRunner.query(`ALTER TABLE "user_list_membership" ADD CONSTRAINT "FK_605472305f26818cc93d1baaa74" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_list_membership" ADD CONSTRAINT "FK_d844bfc6f3f523a05189076efaa" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 76bedf67f..3215a65d3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -39,7 +39,7 @@ "@swc/core-win32-x64-msvc": "1.3.56", "@tensorflow/tfjs": "4.4.0", "@tensorflow/tfjs-node": "4.4.0", - "bufferutil": "^4.0.7", + "bufferutil": "4.0.7", "slacc-android-arm-eabi": "0.0.10", "slacc-android-arm64": "0.0.10", "slacc-darwin-arm64": "0.0.10", @@ -53,7 +53,7 @@ "slacc-linux-x64-musl": "0.0.10", "slacc-win32-arm64-msvc": "0.0.10", "slacc-win32-x64-msvc": "0.0.10", - "utf-8-validate": "^6.0.3" + "utf-8-validate": "6.0.3" }, "dependencies": { "@aws-sdk/client-s3": "3.412.0", @@ -67,18 +67,18 @@ "@fastify/cors": "8.4.0", "@fastify/express": "2.3.0", "@fastify/http-proxy": "9.2.1", - "@fastify/multipart": "7.7.3", + "@fastify/multipart": "8.0.0", "@fastify/static": "6.11.2", "@fastify/view": "8.2.0", "@nestjs/common": "10.2.6", "@nestjs/core": "10.2.6", "@nestjs/testing": "10.2.6", "@peertube/http-signature": "1.7.0", - "@simplewebauthn/server": "8.1.1", + "@simplewebauthn/server": "8.2.0", "@sinonjs/fake-timers": "11.1.0", "@smithy/node-http-handler": "2.1.5", "@swc/cli": "0.1.62", - "@swc/core": "1.3.87", + "@swc/core": "1.3.90", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "6.0.1", @@ -118,7 +118,7 @@ "jsonld": "8.3.1", "jsrsasign": "10.8.6", "megalodon": "workspace:*", - "meilisearch": "0.34.2", + "meilisearch": "0.35.0", "mfm-js": "0.23.3", "microformats-parser": "1.5.2", "mime-types": "2.1.35", @@ -158,7 +158,7 @@ "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", - "systeminformation": "5.21.8", + "systeminformation": "5.21.9", "tinycolor2": "1.6.0", "tmp": "0.2.1", "tsc-alias": "1.8.8", @@ -191,34 +191,34 @@ "@types/jsdom": "21.1.3", "@types/jsonld": "1.5.10", "@types/jsrsasign": "10.5.9", - "@types/mime-types": "2.1.1", - "@types/ms": "0.7.31", - "@types/node": "20.6.4", + "@types/mime-types": "2.1.2", + "@types/ms": "0.7.32", + "@types/node": "20.7.1", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.11", "@types/oauth": "0.9.2", "@types/oauth2orize": "1.11.1", "@types/oauth2orize-pkce": "0.1.0", - "@types/pg": "8.10.2", - "@types/pug": "2.0.6", + "@types/pg": "8.10.3", + "@types/pug": "2.0.7", "@types/punycode": "2.1.0", "@types/qrcode": "1.5.2", "@types/random-seed": "0.3.3", "@types/ratelimiter": "3.4.4", - "@types/rename": "1.0.4", - "@types/sanitize-html": "2.9.0", - "@types/semver": "7.5.2", + "@types/rename": "1.0.5", + "@types/sanitize-html": "2.9.1", + "@types/semver": "7.5.3", "@types/sharp": "0.32.0", - "@types/simple-oauth2": "5.0.4", - "@types/sinonjs__fake-timers": "8.1.2", + "@types/simple-oauth2": "5.0.5", + "@types/sinonjs__fake-timers": "8.1.3", "@types/tinycolor2": "1.4.4", "@types/tmp": "0.2.4", "@types/uuid": "^9.0.4", - "@types/vary": "1.1.0", - "@types/web-push": "3.6.0", - "@types/ws": "8.5.5", - "@typescript-eslint/eslint-plugin": "6.7.2", - "@typescript-eslint/parser": "6.7.2", + "@types/vary": "1.1.1", + "@types/web-push": "3.6.1", + "@types/ws": "8.5.6", + "@typescript-eslint/eslint-plugin": "6.7.3", + "@typescript-eslint/parser": "6.7.3", "aws-sdk-client-mock": "3.0.0", "cross-env": "7.0.3", "eslint": "8.50.0", diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 9f1ee9fca..3e9d19f82 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -70,11 +70,19 @@ const $redisForSub: Provider = { inject: [DI.config], }; +const $redisForTimelines: Provider = { + provide: DI.redisForTimelines, + useFactory: (config: Config) => { + return new Redis.Redis(config.redisForTimelines); + }, + inject: [DI.config], +}; + @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub], - exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule], + providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines], + exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @@ -82,6 +90,7 @@ export class GlobalModule implements OnApplicationShutdown { @Inject(DI.redis) private redisClient: Redis.Redis, @Inject(DI.redisForPub) private redisForPub: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, + @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, ) {} public async dispose(): Promise { @@ -98,6 +107,7 @@ export class GlobalModule implements OnApplicationShutdown { this.redisClient.disconnect(), this.redisForPub.disconnect(), this.redisForSub.disconnect(), + this.redisForTimelines.disconnect(), ]); } diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index e31aa6563..a3f869464 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -66,6 +66,7 @@ export async function masterMain() { showNodejsVersion(); config = loadConfigBoot(); //await connectDb(); + if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString()); } catch (e) { bootLogger.error('Fatal error occurred during initialization', null, true); process.exit(1); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index abbfdfed8..ef59a8095 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -47,6 +47,7 @@ type Source = { redis: RedisOptionsSource; redisForPubsub?: RedisOptionsSource; redisForJobQueue?: RedisOptionsSource; + redisForTimelines?: RedisOptionsSource; meilisearch?: { host: string; port: string; @@ -89,6 +90,7 @@ type Source = { perChannelMaxNoteCacheCount?: number; perUserNotificationsMaxCount?: number; deactivateAntennaThreshold?: number; + pidFile: string; }; export type Config = { @@ -160,9 +162,11 @@ export type Config = { redis: RedisOptions & RedisOptionsSource; redisForPubsub: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource; + redisForTimelines: RedisOptions & RedisOptionsSource; perChannelMaxNoteCacheCount: number; perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; + pidFile: string; }; const _filename = fileURLToPath(import.meta.url); @@ -225,6 +229,7 @@ export function loadConfig(): Config { redis, redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, + redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, id: config.id, proxy: config.proxy, proxySmtp: config.proxySmtp, @@ -255,6 +260,7 @@ export function loadConfig(): Config { perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000, perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 300, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), + pidFile: config.pidFile, }; } diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index ec1d01392..ba3413007 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import { IdService } from '@/core/IdService.js'; @@ -42,8 +42,8 @@ export class AccountMoveService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, @@ -215,40 +215,40 @@ export class AccountMoveService { @bindThis public async updateLists(src: ThinUser, dst: MiUser): Promise { // Return if there is no list to be updated. - const oldJoinings = await this.userListJoiningsRepository.find({ + const oldMemberships = await this.userListMembershipsRepository.find({ where: { userId: src.id, }, }); - if (oldJoinings.length === 0) return; + if (oldMemberships.length === 0) return; - const existingUserListIds = await this.userListJoiningsRepository.find({ + const existingUserListIds = await this.userListMembershipsRepository.find({ where: { userId: dst.id, }, - }).then(joinings => joinings.map(joining => joining.userListId)); + }).then(memberships => memberships.map(membership => membership.userListId)); - const newJoinings: Map = new Map(); + const newMemberships: Map = new Map(); // 重複しないようにIDを生成 const genId = (): string => { let id: string; do { id = this.idService.genId(); - } while (newJoinings.has(id)); + } while (newMemberships.has(id)); return id; }; - for (const joining of oldJoinings) { - if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list - newJoinings.set(genId(), { + for (const membership of oldMemberships) { + if (existingUserListIds.includes(membership.userListId)) continue; // skip if dst exists in this user's list + newMemberships.set(genId(), { createdAt: new Date(), userId: dst.id, - userListId: joining.userListId, + userListId: membership.userListId, }); } - const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); - await this.userListJoiningsRepository.insert(arrayToInsert); + const arrayToInsert = Array.from(newMemberships.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.userListMembershipsRepository.insert(arrayToInsert); // Have the proxy account follow the new account in the same way as UserListService.push if (this.userEntityService.isRemoteUser(dst)) { diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 841ce4b84..95712b35b 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -12,10 +12,10 @@ 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, UserListJoiningsRepository } from '@/models/_.js'; +import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -24,8 +24,8 @@ export class AntennaService implements OnApplicationShutdown { private antennas: MiAntenna[]; constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @@ -33,8 +33,8 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private utilityService: UtilityService, private globalEventService: GlobalEventService, @@ -50,7 +50,7 @@ export class AntennaService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'antennaCreated': this.antennas.push({ @@ -81,7 +81,7 @@ export class AntennaService implements OnApplicationShutdown { const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); - const redisPipeline = this.redisClient.pipeline(); + const redisPipeline = this.redisForTimelines.pipeline(); for (const antenna of matchedAntennas) { redisPipeline.xadd( @@ -108,7 +108,7 @@ export class AntennaService implements OnApplicationShutdown { if (antenna.src === 'home') { // TODO } else if (antenna.src === 'list') { - const listUsers = (await this.userListJoiningsRepository.findBy({ + const listUsers = (await this.userListMembershipsRepository.findBy({ userListId: antenna.userListId!, })).map(x => x.userId); diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 6ca684d53..22c510cc3 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -5,13 +5,13 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown { public userBlockingCache: RedisKVCache>; public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: RedisKVCache>; - public userFollowingsCache: RedisKVCache>; + public userFollowingsCache: RedisKVCache | undefined>>; public userFollowingChannelsCache: RedisKVCache>; constructor( @@ -136,12 +136,18 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => new Set(JSON.parse(value)), }); - this.userFollowingsCache = new RedisKVCache>(this.redisClient, 'userFollowings', { + this.userFollowingsCache = new RedisKVCache | undefined>>(this.redisClient, 'userFollowings', { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), + fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => { + const obj: Record | undefined> = {}; + for (const x of xs) { + obj[x.followeeId] = { withReplies: x.withReplies }; + } + return obj; + }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), }); this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { @@ -160,7 +166,7 @@ export class CacheService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'userChangeSuspendedState': case 'remoteUserUpdated': { @@ -188,6 +194,7 @@ export class CacheService implements OnApplicationShutdown { if (follower) follower.followingCount++; const followee = this.userByIdCache.get(body.followeeId); if (followee) followee.followersCount++; + this.userFollowingsCache.delete(body.followerId); break; } default: diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 7d6b76e9c..955b9fdcf 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -47,6 +47,7 @@ import { SignupService } from './SignupService.js'; import { WebAuthnService } from './WebAuthnService.js'; import { UserBlockingService } from './UserBlockingService.js'; import { CacheService } from './CacheService.js'; +import { UserService } from './UserService.js'; import { UserFollowingService } from './UserFollowingService.js'; import { UserKeypairService } from './UserKeypairService.js'; import { UserListService } from './UserListService.js'; @@ -175,6 +176,7 @@ const $SignupService: Provider = { provide: 'SignupService', useExisting: Signup const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; +const $UserService: Provider = { provide: 'UserService', useExisting: UserService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; @@ -306,6 +308,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebAuthnService, UserBlockingService, CacheService, + UserService, UserFollowingService, UserKeypairService, UserListService, @@ -430,6 +433,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebAuthnService, $UserBlockingService, $CacheService, + $UserService, $UserFollowingService, $UserKeypairService, $UserListService, @@ -555,6 +559,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebAuthnService, UserBlockingService, CacheService, + UserService, UserFollowingService, UserKeypairService, UserListService, @@ -678,6 +683,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebAuthnService, $UserBlockingService, $CacheService, + $UserService, $UserFollowingService, $UserKeypairService, $UserListService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index f9368eb6d..d1cd2e97c 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -17,7 +17,7 @@ import { bindThis } from '@/decorators.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import { query } from '@/misc/prelude/url.js'; -import type { Serialized } from '@/server/api/stream/types.js'; +import type { Serialized } from '@/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import type { Config } from '@/config.js'; diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 4bc4f54c2..b74fbbe58 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -5,27 +5,254 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import type { MiChannel } from '@/models/Channel.js'; import type { MiUser } from '@/models/User.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; import type { MiNote } from '@/models/Note.js'; -import type { MiUserList } from '@/models/UserList.js'; import type { MiAntenna } from '@/models/Antenna.js'; -import type { - StreamChannels, - AdminStreamTypes, - AntennaStreamTypes, - BroadcastTypes, - DriveStreamTypes, - InternalStreamTypes, - MainStreamTypes, - NoteStreamTypes, - UserListStreamTypes, - RoleTimelineStreamTypes, -} from '@/server/api/stream/types.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiDriveFolder } from '@/models/DriveFolder.js'; +import type { MiUserList } from '@/models/UserList.js'; +import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import type { MiSignin } from '@/models/Signin.js'; +import type { MiPage } from '@/models/Page.js'; +import type { MiWebhook } from '@/models/Webhook.js'; +import type { MiMeta } from '@/models/Meta.js'; +import { MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; -import { MiRole } from '@/models/_.js'; +import { Serialized } from '@/types.js'; +import type Emitter from 'strict-event-emitter-types'; +import type { EventEmitter } from 'events'; + +//#region Stream type-body definitions +export interface BroadcastTypes { + emojiAdded: { + emoji: Packed<'EmojiDetailed'>; + }; + emojiUpdated: { + emojis: Packed<'EmojiDetailed'>[]; + }; + emojiDeleted: { + emojis: { + id?: string; + name: string; + [other: string]: any; + }[]; + }; + announcementCreated: { + announcement: Packed<'Announcement'>; + }; +} + +export interface MainEventTypes { + notification: Packed<'Notification'>; + mention: Packed<'Note'>; + reply: Packed<'Note'>; + renote: Packed<'Note'>; + follow: Packed<'UserDetailedNotMe'>; + followed: Packed<'User'>; + unfollow: Packed<'User'>; + meUpdated: Packed<'User'>; + pageEvent: { + pageId: MiPage['id']; + event: string; + var: any; + userId: MiUser['id']; + user: Packed<'User'>; + }; + urlUploadFinished: { + marker?: string | null; + file: Packed<'DriveFile'>; + }; + readAllNotifications: undefined; + unreadNotification: Packed<'Notification'>; + unreadMention: MiNote['id']; + readAllUnreadMentions: undefined; + unreadSpecifiedNote: MiNote['id']; + readAllUnreadSpecifiedNotes: undefined; + readAllAntennas: undefined; + unreadAntenna: MiAntenna; + readAllAnnouncements: undefined; + myTokenRegenerated: undefined; + signin: MiSignin; + registryUpdated: { + scope?: string[]; + key: string; + value: any | null; + }; + driveFileCreated: Packed<'DriveFile'>; + readAntenna: MiAntenna; + receiveFollowRequest: Packed<'User'>; + announcementCreated: { + announcement: Packed<'Announcement'>; + }; +} + +export interface DriveEventTypes { + fileCreated: Packed<'DriveFile'>; + fileDeleted: MiDriveFile['id']; + fileUpdated: Packed<'DriveFile'>; + folderCreated: Packed<'DriveFolder'>; + folderDeleted: MiDriveFolder['id']; + folderUpdated: Packed<'DriveFolder'>; +} + +export interface NoteEventTypes { + pollVoted: { + choice: number; + userId: MiUser['id']; + }; + deleted: { + deletedAt: Date; + }; + updated: { + cw: string | null; + text: string; + }; + reacted: { + reaction: string; + emoji?: { + name: string; + url: string; + } | null; + userId: MiUser['id']; + }; + unreacted: { + reaction: string; + userId: MiUser['id']; + }; +} +type NoteStreamEventTypes = { + [key in keyof NoteEventTypes]: { + id: MiNote['id']; + body: NoteEventTypes[key]; + }; +}; + +export interface UserListEventTypes { + userAdded: Packed<'User'>; + userRemoved: Packed<'User'>; +} + +export interface AntennaEventTypes { + note: MiNote; +} + +export interface RoleTimelineEventTypes { + note: Packed<'Note'>; +} + +export interface AdminEventTypes { + newAbuseUserReport: { + id: MiAbuseUserReport['id']; + targetUserId: MiUser['id'], + reporterId: MiUser['id'], + comment: string; + }; +} +//#endregion + +// 辞書(interface or type)から{ type, body }ユニオンを定義 +// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type +// VS Codeの展開を防止するためにEvents型を定義 +type Events = { [K in keyof T]: { type: K; body: T[K]; } }; +type EventUnionFromDictionary< + T extends object, + U = Events +> = U[keyof U]; + +type SerializedAll = { + [K in keyof T]: Serialized; +}; + +export interface InternalEventTypes { + userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; + userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; }; + remoteUserUpdated: { id: MiUser['id']; }; + follow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; + unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; + blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; + blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; + policiesUpdated: MiRole['policies']; + roleCreated: MiRole; + roleDeleted: MiRole; + roleUpdated: MiRole; + userRoleAssigned: MiRoleAssignment; + userRoleUnassigned: MiRoleAssignment; + webhookCreated: MiWebhook; + webhookDeleted: MiWebhook; + webhookUpdated: MiWebhook; + antennaCreated: MiAntenna; + antennaDeleted: MiAntenna; + antennaUpdated: MiAntenna; + metaUpdated: MiMeta; + followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; + unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; + updateUserProfile: MiUserProfile; + mute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; + unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; + userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; + userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; +} + +// name/messages(spec) pairs dictionary +export type GlobalEvents = { + internal: { + name: 'internal'; + payload: EventUnionFromDictionary>; + }; + broadcast: { + name: 'broadcast'; + payload: EventUnionFromDictionary>; + }; + main: { + name: `mainStream:${MiUser['id']}`; + payload: EventUnionFromDictionary>; + }; + drive: { + name: `driveStream:${MiUser['id']}`; + payload: EventUnionFromDictionary>; + }; + note: { + name: `noteStream:${MiNote['id']}`; + payload: EventUnionFromDictionary>; + }; + userList: { + name: `userListStream:${MiUserList['id']}`; + payload: EventUnionFromDictionary>; + }; + roleTimeline: { + name: `roleTimelineStream:${MiRole['id']}`; + payload: EventUnionFromDictionary>; + }; + antenna: { + name: `antennaStream:${MiAntenna['id']}`; + payload: EventUnionFromDictionary>; + }; + admin: { + name: `adminStream:${MiUser['id']}`; + payload: EventUnionFromDictionary>; + }; + notes: { + name: 'notesStream'; + payload: Serialized>; + }; +}; + +// API event definitions +// ストリームごとのEmitterの辞書を用意 +type EventEmitterDictionary = { [x in keyof GlobalEvents]: Emitter.default void }> }; +// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; +// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする +export type StreamEventEmitter = UnionToIntersection; +// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる + +// provide stream channels union +export type StreamChannels = GlobalEvents[keyof GlobalEvents]['name']; @Injectable() export class GlobalEventService { @@ -51,7 +278,7 @@ export class GlobalEventService { } @bindThis - public publishInternalEvent(type: K, value?: InternalStreamTypes[K]): void { + public publishInternalEvent(type: K, value?: InternalEventTypes[K]): void { this.publish('internal', type, typeof value === 'undefined' ? null : value); } @@ -61,17 +288,17 @@ export class GlobalEventService { } @bindThis - public publishMainStream(userId: MiUser['id'], type: K, value?: MainStreamTypes[K]): void { + public publishMainStream(userId: MiUser['id'], type: K, value?: MainEventTypes[K]): void { this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishDriveStream(userId: MiUser['id'], type: K, value?: DriveStreamTypes[K]): void { + public publishDriveStream(userId: MiUser['id'], type: K, value?: DriveEventTypes[K]): void { this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishNoteStream(noteId: MiNote['id'], type: K, value?: NoteStreamTypes[K]): void { + public publishNoteStream(noteId: MiNote['id'], type: K, value?: NoteEventTypes[K]): void { this.publish(`noteStream:${noteId}`, type, { id: noteId, body: value, @@ -79,17 +306,17 @@ export class GlobalEventService { } @bindThis - public publishUserListStream(listId: MiUserList['id'], type: K, value?: UserListStreamTypes[K]): void { + public publishUserListStream(listId: MiUserList['id'], type: K, value?: UserListEventTypes[K]): void { this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishAntennaStream(antennaId: MiAntenna['id'], type: K, value?: AntennaStreamTypes[K]): void { + public publishAntennaStream(antennaId: MiAntenna['id'], type: K, value?: AntennaEventTypes[K]): void { this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishRoleTimelineStream(roleId: MiRole['id'], type: K, value?: RoleTimelineStreamTypes[K]): void { + public publishRoleTimelineStream(roleId: MiRole['id'], type: K, value?: RoleTimelineEventTypes[K]): void { this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value); } @@ -99,7 +326,7 @@ export class GlobalEventService { } @bindThis - public publishAdminStream(userId: MiUser['id'], type: K, value?: AdminStreamTypes[K]): void { + public publishAdminStream(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void { this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); } } diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 00e1e3c1f..508544dc0 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js'; import { MiMeta } from '@/models/Meta.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -46,7 +46,7 @@ export class MetaService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'metaUpdated': { this.cache = body; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 972319ddc..8fb34fd63 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -5,7 +5,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; -import { In, DataSource } from 'typeorm'; +import { In, DataSource, IsNull, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import RE2 from 're2'; @@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -54,8 +54,6 @@ import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; -const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5); - type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; class NotificationManager { @@ -110,9 +108,8 @@ class NotificationManager { // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する if (!mentioneesMutedUserIds.includes(this.notifier.id)) { this.notificationService.createNotification(x.target, x.reason, { - notifierId: this.notifier.id, noteId: this.note.id, - }); + }, this.notifier.id); } } } @@ -158,8 +155,8 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.db) private db: DataSource, - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -176,8 +173,8 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.mutedNotesRepository) - private mutedNotesRepository: MutedNotesRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, @@ -188,6 +185,9 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private idService: IdService, @@ -335,7 +335,7 @@ export class NoteCreateService implements OnApplicationShutdown { const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); if (data.channel) { - this.redisClient.xadd( + this.redisForTimelines.xadd( `channelTimeline:${data.channel.id}`, 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), '*', @@ -481,26 +481,13 @@ export class NoteCreateService implements OnApplicationShutdown { // Increment notes count (user) this.incNotesCountOfUser(user); - // Word mute - mutedWordsCache.fetch(() => this.userProfilesRepository.find({ - where: { - enableWordMute: true, - }, - select: ['userId', 'mutedWords'], - })).then(us => { - for (const u of us) { - checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { - if (shouldMute) { - this.mutedNotesRepository.insert({ - id: this.idService.genId(), - userId: u.userId, - noteId: note.id, - reason: 'word', - }); - } - }); - } - }); + if (data.visibility === 'public' || data.visibility === 'home') { + this.pushToTl(note, user); + } else if (data.visibility === 'followers') { + this.pushToTl(note, user); + } else if (data.visibility === 'specified') { + // TODO + } this.antennaService.addNoteToAntennas(note, user); @@ -509,15 +496,16 @@ export class NoteCreateService implements OnApplicationShutdown { } if (data.reply == null) { + // TODO: キャッシュ this.followingsRepository.findBy({ followeeId: user.id, notify: 'normal', }).then(followings => { for (const following of followings) { + // TODO: ワードミュート考慮 this.notificationService.createNotification(following.followerId, 'note', { - notifierId: user.id, noteId: note.id, - }); + }, user.id); } }); } @@ -813,6 +801,205 @@ export class NoteCreateService implements OnApplicationShutdown { return mentionedUsers; } + @bindThis + private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { + const redisPipeline = this.redisForTimelines.pipeline(); + + if (note.channelId) { + const channelFollowings = await this.channelFollowingsRepository.find({ + where: { + followeeId: note.channelId, + }, + select: ['followerId'], + }); + + for (const channelFollowing of channelFollowings) { + redisPipeline.xadd( + `homeTimeline:${channelFollowing.followerId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${channelFollowing.followerId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + } else { + // TODO: キャッシュ? + const followings = await this.followingsRepository.find({ + where: { + followeeId: user.id, + followerHost: IsNull(), + isFollowerHibernated: false, + }, + select: ['followerId', 'withReplies'], + }); + + const userListMemberships = await this.userListMembershipsRepository.find({ + where: { + userId: user.id, + }, + select: ['userListId', 'withReplies'], + }); + + // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする + for (const following of followings) { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + if (!following.withReplies) continue; + } + + redisPipeline.xadd( + `homeTimeline:${following.followerId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${following.followerId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + // TODO + //if (note.visibility === 'followers') { + // // TODO: 重そうだから何とかしたい Set 使う? + // userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId)); + //} + + for (const userListMembership of userListMemberships) { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + if (!userListMembership.withReplies) continue; + } + + redisPipeline.xadd( + `userListTimeline:${userListMembership.userListId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `userListTimelineWithFiles:${userListMembership.userListId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + { // 自分自身のHTL + redisPipeline.xadd( + `homeTimeline:${user.id}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${user.id}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + if (note.visibility === 'public' || note.visibility === 'home') { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + redisPipeline.xadd( + `userTimelineWithReplies:${user.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + } else { + redisPipeline.xadd( + `userTimeline:${user.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `userTimelineWithFiles:${user.id}`, + 'MAXLEN', '~', '500', + '*', + 'note', note.id); + } + + if (note.visibility === 'public' && note.userHost == null) { + redisPipeline.xadd( + 'localTimeline', + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + 'localTimelineWithFiles', + 'MAXLEN', '~', '500', + '*', + 'note', note.id); + } + } + } + } + + if (Math.random() < 0.1) { + process.nextTick(() => { + this.checkHibernation(followings); + }); + } + } + + redisPipeline.exec(); + } + + @bindThis + public async checkHibernation(followings: MiFollowing[]) { + if (followings.length === 0) return; + + const shuffle = (array: MiFollowing[]) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + // ランダムに最大1000件サンプリング + const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); + + const hibernatedUsers = await this.usersRepository.find({ + where: { + id: In(samples.map(x => x.followerId)), + lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), + }, + select: ['id'], + }); + + if (hibernatedUsers.length > 0) { + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }); + + this.followingsRepository.update({ + followerId: In(hibernatedUsers.map(x => x.id)), + }, { + isFollowerHibernated: true, + }); + } + } + @bindThis public dispose(): void { this.#shutdownController.abort(); diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 81a493013..f331c8a9a 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -5,7 +5,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; -import { In } from 'typeorm'; +import { In, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import RE2 from 're2'; @@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository, UserListMembershipsRepository, ChannelFollowingsRepository, MiFollowing } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -49,8 +49,6 @@ import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; -const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5); - type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; class NotificationManager { @@ -105,9 +103,8 @@ class NotificationManager { // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する if (!mentioneesMutedUserIds.includes(this.notifier.id)) { this.notificationService.createNotification(x.target, x.reason, { - notifierId: this.notifier.id, noteId: this.note.id, - }); + }, this.notifier.id); } } } @@ -152,8 +149,8 @@ export class NoteEditService implements OnApplicationShutdown { @Inject(DI.config) private config: Config, - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -170,12 +167,15 @@ export class NoteEditService implements OnApplicationShutdown { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.mutedNotesRepository) - private mutedNotesRepository: MutedNotesRepository, - @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.noteThreadMutingsRepository) private noteThreadMutingsRepository: NoteThreadMutingsRepository, @@ -423,7 +423,7 @@ export class NoteEditService implements OnApplicationShutdown { await this.notesRepository.update(oldnote.id, note); if (data.channel) { - this.redisClient.xadd( + this.redisForTimelines.xadd( `channelTimeline:${data.channel.id}`, 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), '*', @@ -461,27 +461,6 @@ export class NoteEditService implements OnApplicationShutdown { this.hashtagService.updateHashtags(user, tags); } - // Word mute - mutedWordsCache.fetch(() => this.userProfilesRepository.find({ - where: { - enableWordMute: true, - }, - select: ['userId', 'mutedWords'], - })).then(us => { - for (const u of us) { - checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { - if (shouldMute) { - this.mutedNotesRepository.insert({ - id: this.idService.genId(), - userId: u.userId, - noteId: note.id, - reason: 'word', - }); - } - }); - } - }); - if (data.poll && data.poll.expiresAt) { const delay = data.poll.expiresAt.getTime() - Date.now(); this.queueService.endedPollNotificationQueue.add(note.id, { @@ -492,6 +471,14 @@ export class NoteEditService implements OnApplicationShutdown { }); } + if (data.visibility === 'public' || data.visibility === 'home') { + this.pushToTl(note, user); + } else if (data.visibility === 'followers') { + this.pushToTl(note, user); + } else if (data.visibility === 'specified') { + // TODO + } + if (!silent) { if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); @@ -524,7 +511,8 @@ export class NoteEditService implements OnApplicationShutdown { const noteObj = await this.noteEntityService.pack(note); this.globalEventService.publishNoteStream(note.id, 'updated', { - updatedAt: note.updatedAt!, + cw: note.cw, + text: note.text!, }); this.roleService.addNoteToRoleTimeline(noteObj); @@ -651,6 +639,163 @@ export class NoteEditService implements OnApplicationShutdown { this.index(note); } + @bindThis + private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { + const redisPipeline = this.redisForTimelines.pipeline(); + + if (note.channelId) { + const channelFollowings = await this.channelFollowingsRepository.find({ + where: { + followeeId: note.channelId, + }, + select: ['followerId'], + }); + + for (const channelFollowing of channelFollowings) { + redisPipeline.xadd( + `homeTimeline:${channelFollowing.followerId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${channelFollowing.followerId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + } else { + // TODO: キャッシュ? + + const userListMemberships = await this.userListMembershipsRepository.find({ + where: { + userId: user.id, + }, + select: ['userListId', 'withReplies'], + }); + + // TODO + //if (note.visibility === 'followers') { + // // TODO: 重そうだから何とかしたい Set 使う? + // userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId)); + //} + + for (const userListMembership of userListMemberships) { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + if (!userListMembership.withReplies) continue; + } + + redisPipeline.xadd( + `userListTimeline:${userListMembership.userListId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `userListTimelineWithFiles:${userListMembership.userListId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + { // 自分自身のHTL + redisPipeline.xadd( + `homeTimeline:${user.id}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${user.id}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + if (note.visibility === 'public' || note.visibility === 'home') { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + redisPipeline.xadd( + `userTimelineWithReplies:${user.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + } else { + redisPipeline.xadd( + `userTimeline:${user.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `userTimelineWithFiles:${user.id}`, + 'MAXLEN', '~', '500', + '*', + 'note', note.id); + } + + if (note.visibility === 'public' && note.userHost == null) { + redisPipeline.xadd( + 'localTimeline', + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + 'localTimelineWithFiles', + 'MAXLEN', '~', '500', + '*', + 'note', note.id); + } + } + } + } + } + + redisPipeline.exec(); + } + + @bindThis + public async checkHibernation(followings: MiFollowing[]) { + if (followings.length === 0) return; + + const shuffle = (array: MiFollowing[]) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + // ランダムに最大1000件サンプリング + const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); + + const hibernatedUsers = await this.usersRepository.find({ + where: { + id: In(samples.map(x => x.followerId)), + lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), + }, + select: ['id'], + }); + + if (hibernatedUsers.length > 0) { + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }); + } + } + @bindThis private isSensitive(note: Option, sensitiveWord: string[]): boolean { if (sensitiveWord.length > 0) { diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 258ae44f7..32d54d257 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -18,6 +18,7 @@ import { NotificationEntityService } from '@/core/entities/NotificationEntitySer import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; +import { UserListService } from '@/core/UserListService.js'; @Injectable() export class NotificationService implements OnApplicationShutdown { @@ -38,6 +39,7 @@ export class NotificationService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private pushNotificationService: PushNotificationService, private cacheService: CacheService, + private userListService: UserListService, ) { } @@ -74,27 +76,59 @@ export class NotificationService implements OnApplicationShutdown { public async createNotification( notifieeId: MiUser['id'], type: MiNotification['type'], - data: Partial, + data: Omit, 'notifierId'>, + notifierId?: MiUser['id'] | null, ): Promise { const profile = await this.cacheService.userProfileCache.fetch(notifieeId); - const isMuted = profile.mutingNotificationTypes.includes(type); - if (isMuted) return null; - if (data.notifierId) { - if (notifieeId === data.notifierId) { + // 古いMisskeyバージョンのキャッシュが残っている可能性がある + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const recieveConfig = (profile.notificationRecieveConfig ?? {})[type]; + if (recieveConfig?.type === 'never') { + return null; + } + + if (notifierId) { + if (notifieeId === notifierId) { return null; } const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); - if (mutings.has(data.notifierId)) { + if (mutings.has(notifierId)) { return null; } + + if (recieveConfig?.type === 'following') { + const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)); + if (!isFollowing) { + return null; + } + } else if (recieveConfig?.type === 'follower') { + const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)); + if (!isFollower) { + return null; + } + } else if (recieveConfig?.type === 'mutualFollow') { + const [isFollowing, isFollower] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)), + this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)), + ]); + if (!isFollowing && !isFollower) { + return null; + } + } else if (recieveConfig?.type === 'list') { + const isMember = await this.userListService.membersCache.fetch(recieveConfig.userListId).then(members => members.has(notifierId)); + if (!isMember) { + return null; + } + } } const notification = { id: this.idService.genId(), createdAt: new Date(), type: type, + notifierId: notifierId, ...data, } as MiNotification; @@ -117,8 +151,8 @@ export class NotificationService implements OnApplicationShutdown { this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); - if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); - if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! })); + if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! })); }, () => { /* aborted, ignore it */ }); return notification; diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 9145726f8..18bd49286 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, ObjectLiteral } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { SelectQueryBuilder } from 'typeorm'; @@ -23,9 +23,6 @@ export class QueryService { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.mutedNotesRepository) - private mutedNotesRepository: MutedNotesRepository, - @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -108,39 +105,6 @@ export class QueryService { q.setParameters(blockedQuery.getParameters()); } - @bindThis - public generateChannelQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): void { - if (me == null) { - q.andWhere('note.channelId IS NULL'); - } else { - q.leftJoinAndSelect('note.channel', 'channel'); - - const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing') - .select('channelFollowing.followeeId') - .where('channelFollowing.followerId = :followerId', { followerId: me.id }); - - q.andWhere(new Brackets(qb => { qb - // チャンネルのノートではない - .where('note.channelId IS NULL') - // または自分がフォローしているチャンネルのノート - .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); - })); - - q.setParameters(channelFollowingQuery.getParameters()); - } - } - - @bindThis - public generateMutedNoteQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted') - .select('muted.noteId') - .where('muted.userId = :userId', { userId: me.id }); - - q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); - - q.setParameters(mutedQuery.getParameters()); - } - @bindThis public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') @@ -212,32 +176,6 @@ export class QueryService { q.setParameters(mutingQuery.getParameters()); } - @bindThis - public generateRepliesQuery(q: SelectQueryBuilder, withReplies: boolean, me?: Pick | null): void { - if (me == null) { - q.andWhere(new Brackets(qb => { qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); - } else if (!withReplies) { - q.andWhere(new Brackets(qb => { qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 - .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.userId = :meId', { meId: me.id }); - })) - .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); - } - } - @bindThis public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): void { // This code must always be synchronized with the checks in Notes.isVisibleForMe. diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index d9bde502c..25464b19a 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -219,10 +219,9 @@ export class ReactionService { // リアクションされたユーザーがローカルユーザーなら通知を作成 if (note.userHost === null) { this.notificationService.createNotification(note.userId, 'reaction', { - notifierId: user.id, noteId: note.id, reaction: reaction, - }); + }, user.id); } //#region 配信 diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 934b7d676..f2bd9de5e 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -15,7 +15,7 @@ import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; import type { RoleCondFormulaValue } from '@/models/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -32,6 +32,7 @@ export type RolePolicies = { inviteExpirationTime: number; canManageCustomEmojis: boolean; canSearchNotes: boolean; + canUseTranslator: boolean; canHideAds: boolean; driveCapacityMb: number; alwaysMarkNsfw: boolean; @@ -56,6 +57,7 @@ export const DEFAULT_POLICIES: RolePolicies = { inviteExpirationTime: 0, canManageCustomEmojis: false, canSearchNotes: false, + canUseTranslator: true, canHideAds: false, driveCapacityMb: 100, alwaysMarkNsfw: false, @@ -114,7 +116,7 @@ export class RoleService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'roleCreated': { const cached = this.rolesCache.get(); @@ -300,6 +302,7 @@ export class RoleService implements OnApplicationShutdown { inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), + canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 37031e341..087dfd921 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -11,7 +11,7 @@ import type { MiBlocking } from '@/models/Blocking.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; +import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListMembershipsRepository } from '@/models/_.js'; import Logger from '@/logger.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -38,8 +38,8 @@ export class UserBlockingService implements OnModuleInit { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private cacheService: CacheService, private userEntityService: UserEntityService, @@ -149,7 +149,7 @@ export class UserBlockingService implements OnModuleInit { }); for (const userList of userLists) { - await this.userListJoiningsRepository.delete({ + await this.userListMembershipsRepository.delete({ userListId: userList.id, userId: user.id, }); diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 5b2b0205d..beffcc2e9 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -123,7 +123,11 @@ export class UserFollowingService implements OnModuleInit { // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく - if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) { + if ( + followee.isLocked || + (followeeProfile.carefulBot && follower.isBot) || + (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') + ) { let autoAccept = false; // 鍵アカウントであっても、既にフォローされていた場合はスルー @@ -230,8 +234,7 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(follower.id, 'followRequestAccepted', { - notifierId: followee.id, - }); + }, followee.id); } if (alreadyFollowed) return; @@ -304,8 +307,7 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(followee.id, 'follow', { - notifierId: follower.id, - }); + }, follower.id); } } @@ -488,9 +490,8 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { - notifierId: follower.id, followRequestId: followRequest.id, - }); + }, follower.id); } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index a71d50bba..bece1e442 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListJoiningsRepository } from '@/models/_.js'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { UserListMembershipsRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import type { MiUserList } from '@/models/UserList.js'; -import type { MiUserListJoining } from '@/models/UserListJoining.js'; +import type { MiUserListMembership } from '@/models/UserListMembership.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -16,14 +17,24 @@ import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { QueueService } from '@/core/QueueService.js'; +import { RedisKVCache } from '@/misc/cache.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; @Injectable() -export class UserListService { +export class UserListService implements OnApplicationShutdown { public static TooManyUsersError = class extends Error {}; + public membersCache: RedisKVCache>; + constructor( - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private userEntityService: UserEntityService, private idService: IdService, @@ -32,24 +43,63 @@ export class UserListService { private proxyAccountService: ProxyAccountService, private queueService: QueueService, ) { + this.membersCache = new RedisKVCache>(this.redisClient, 'userListMembers', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.redisForSub.on('message', this.onMessage); } @bindThis - public async push(target: MiUser, list: MiUserList, me: MiUser) { - const currentCount = await this.userListJoiningsRepository.countBy({ + 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 'userListMemberAdded': { + const { userListId, memberId } = body; + const members = await this.membersCache.get(userListId); + if (members) { + members.add(memberId); + } + break; + } + case 'userListMemberRemoved': { + const { userListId, memberId } = body; + const members = await this.membersCache.get(userListId); + if (members) { + members.delete(memberId); + } + break; + } + default: + break; + } + } + } + + @bindThis + public async addMember(target: MiUser, list: MiUserList, me: MiUser) { + const currentCount = await this.userListMembershipsRepository.countBy({ userListId: list.id, }); if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { throw new UserListService.TooManyUsersError(); } - await this.userListJoiningsRepository.insert({ + await this.userListMembershipsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), userId: target.id, userListId: list.id, - } as MiUserListJoining); + } as MiUserListMembership); + this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id }); this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする @@ -60,4 +110,44 @@ export class UserListService { } } } + + @bindThis + public async removeMember(target: MiUser, list: MiUserList) { + await this.userListMembershipsRepository.delete({ + userId: target.id, + userListId: list.id, + }); + + this.globalEventService.publishInternalEvent('userListMemberRemoved', { userListId: list.id, memberId: target.id }); + this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target)); + } + + @bindThis + public async updateMembership(target: MiUser, list: MiUserList, options: { withReplies?: boolean }) { + const membership = await this.userListMembershipsRepository.findOneBy({ + userId: target.id, + userListId: list.id, + }); + + if (membership == null) { + throw new Error('User is not a member of the list'); + } + + await this.userListMembershipsRepository.update({ + id: membership.id, + }, { + withReplies: options.withReplies, + }); + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + this.membersCache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts new file mode 100644 index 000000000..d16e1be61 --- /dev/null +++ b/packages/backend/src/core/UserService.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { FollowingsRepository, UsersRepository } from '@/models/_.js'; +import type { MiUser } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class UserService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + ) { + } + + @bindThis + public async updateLastActiveDate(user: MiUser): Promise { + if (user.isHibernated) { + const result = await this.usersRepository.createQueryBuilder().update() + .set({ + lastActiveDate: new Date(), + }) + .where('id = :id', { id: user.id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); + const wokeUp = result.isHibernated; + if (wokeUp) { + this.usersRepository.update(user.id, { + isHibernated: false, + }); + this.followingsRepository.update({ + followerId: user.id, + }, { + isFollowerHibernated: false, + }); + } + } else { + this.usersRepository.update(user.id, { + lastActiveDate: new Date(), + }); + } + } +} diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 1344f0ac9..ff70f7bc0 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -9,7 +9,7 @@ import type { WebhooksRepository } from '@/models/_.js'; import type { MiWebhook } from '@/models/Webhook.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -45,7 +45,7 @@ export class WebhookService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'webhookCreated': if (body.active) { diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index b3e140e58..4444e4118 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -102,13 +102,13 @@ export class NoteEntityService implements OnModuleInit { } else if (meId === packedNote.userId) { hide = false; } else if (packedNote.reply && (meId === packedNote.reply.userId)) { - // 自分の投稿に対するリプライ + // 自分の投稿に対するリプライ hide = false; } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { - // 自分へのメンション + // 自分へのメンション hide = false; } else { - // フォロワーかどうか + // フォロワーかどうか const isFollowing = await this.followingsRepository.exist({ where: { followeeId: packedNote.userId, @@ -312,6 +312,7 @@ export class NoteEntityService implements OnModuleInit { const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: note.createdAt.toISOString(), + updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, userId: note.userId, user: this.userEntityService.pack(note.user ?? note.userId, me, { detail: false, @@ -342,7 +343,6 @@ export class NoteEntityService implements OnModuleInit { mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, url: note.url ?? undefined, - updatedAt: note.updatedAt != null ? note.updatedAt.toISOString() : undefined, ...(meId ? { myReaction: this.populateMyReaction(note, meId, options?._hint_), } : {}), diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index e1ba71ffd..b8fb9f8db 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -458,7 +458,8 @@ export class UserEntityService implements OnModuleInit { hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), mutedWords: profile!.mutedWords, mutedInstances: profile!.mutedInstances, - mutingNotificationTypes: profile!.mutingNotificationTypes, + mutingNotificationTypes: [], // 後方互換性のため + notificationRecieveConfig: profile!.notificationRecieveConfig, emailNotificationTypes: profile!.emailNotificationTypes, achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, @@ -492,6 +493,7 @@ export class UserEntityService implements OnModuleInit { isMuted: relation.isMuted, isRenoteMuted: relation.isRenoteMuted, notify: relation.following?.notify ?? 'none', + withReplies: relation.following?.withReplies ?? false, } : {}), } as Promiseable> as Promiseable>; diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index a7f288519..06b6e852b 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -5,11 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; +import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/Blocking.js'; import type { MiUserList } from '@/models/UserList.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class UserListEntityService { @@ -17,8 +18,10 @@ export class UserListEntityService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + + private userEntityService: UserEntityService, ) { } @@ -28,7 +31,7 @@ export class UserListEntityService { ): Promise> { const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }); - const users = await this.userListJoiningsRepository.findBy({ + const users = await this.userListMembershipsRepository.findBy({ userListId: userList.id, }); @@ -40,5 +43,18 @@ export class UserListEntityService { isPublic: userList.isPublic, }; } + + @bindThis + public async packMembershipsMany( + memberships: MiUserListMembership[], + ) { + return Promise.all(memberships.map(async x => ({ + id: x.id, + createdAt: x.createdAt.toISOString(), + userId: x.userId, + user: await this.userEntityService.pack(x.userId), + withReplies: x.withReplies, + }))); + } } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 7034fff05..ccaa810f5 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -10,6 +10,7 @@ export const DI = { redis: Symbol('redis'), redisForPub: Symbol('redisForPub'), redisForSub: Symbol('redisForSub'), + redisForTimelines: Symbol('redisForTimelines'), //#region Repositories usersRepository: Symbol('usersRepository'), @@ -30,7 +31,7 @@ export const DI = { userPublickeysRepository: Symbol('userPublickeysRepository'), userListsRepository: Symbol('userListsRepository'), userListFavoritesRepository: Symbol('userListFavoritesRepository'), - userListJoiningsRepository: Symbol('userListJoiningsRepository'), + userListMembershipsRepository: Symbol('userListMembershipsRepository'), userNotePiningsRepository: Symbol('userNotePiningsRepository'), userIpsRepository: Symbol('userIpsRepository'), usedUsernamesRepository: Symbol('usedUsernamesRepository'), @@ -63,7 +64,6 @@ export const DI = { promoNotesRepository: Symbol('promoNotesRepository'), promoReadsRepository: Symbol('promoReadsRepository'), relaysRepository: Symbol('relaysRepository'), - mutedNotesRepository: Symbol('mutedNotesRepository'), channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), channelFavoritesRepository: Symbol('channelFavoritesRepository'), diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 8c9f965fa..607538b1e 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -9,6 +9,7 @@ import { MiUser } from './User.js'; @Entity('following') @Index(['followerId', 'followeeId'], { unique: true }) +@Index(['followeeId', 'followerHost', 'isFollowerHibernated']) export class MiFollowing { @PrimaryColumn(id()) public id: string; @@ -45,6 +46,17 @@ export class MiFollowing { @JoinColumn() public follower: MiUser | null; + @Column('boolean', { + default: false, + }) + public isFollowerHibernated: boolean; + + // タイムラインにその人のリプライまで含めるかどうか + @Column('boolean', { + default: false, + }) + public withReplies: boolean; + @Index() @Column('varchar', { length: 32, diff --git a/packages/backend/src/models/MutedNote.ts b/packages/backend/src/models/MutedNote.ts deleted file mode 100644 index 89a678a2a..000000000 --- a/packages/backend/src/models/MutedNote.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; -import { mutedNoteReasons } from '@/types.js'; -import { id } from './util/id.js'; -import { MiNote } from './Note.js'; -import { MiUser } from './User.js'; - -@Entity('muted_note') -@Index(['noteId', 'userId'], { unique: true }) -export class MiMutedNote { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The note ID.', - }) - public noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; - - @Index() - @Column({ - ...id(), - comment: 'The user ID.', - }) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - /** - * ミュートされた理由。 - */ - @Index() - @Column('enum', { - enum: mutedNoteReasons, - comment: 'The reason of the MutedNote.', - }) - public reason: typeof mutedNoteReasons[number]; -} diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 6855f83b6..4d2a04503 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -24,6 +24,12 @@ export class MiNote { }) public createdAt: Date; + @Column('timestamp with time zone', { + comment: 'The update time of the Note.', + default: null, + }) + public updatedAt: Date | null; + @Index() @Column({ ...id(), @@ -239,12 +245,6 @@ export class MiNote { comment: '[Denormalized]', }) public renoteUserHost: string | null; - - @Index() - @Column('timestamp with time zone', { - comment: 'The update time of the Note.', - }) - public updatedAt: Date | null; //#endregion constructor(data: Partial) { diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 7e2bee8c4..bef053610 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -5,7 +5,7 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js'; +import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -117,9 +117,9 @@ const $userListFavoritesRepository: Provider = { inject: [DI.db], }; -const $userListJoiningsRepository: Provider = { - provide: DI.userListJoiningsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserListJoining), +const $userListMembershipsRepository: Provider = { + provide: DI.userListMembershipsRepository, + useFactory: (db: DataSource) => db.getRepository(MiUserListMembership), inject: [DI.db], }; @@ -315,12 +315,6 @@ const $relaysRepository: Provider = { inject: [DI.db], }; -const $mutedNotesRepository: Provider = { - provide: DI.mutedNotesRepository, - useFactory: (db: DataSource) => db.getRepository(MiMutedNote), - inject: [DI.db], -}; - const $channelsRepository: Provider = { provide: DI.channelsRepository, useFactory: (db: DataSource) => db.getRepository(MiChannel), @@ -427,7 +421,7 @@ const $noteEditRepository: Provider = { $userPublickeysRepository, $userListsRepository, $userListFavoritesRepository, - $userListJoiningsRepository, + $userListMembershipsRepository, $userNotePiningsRepository, $userIpsRepository, $usedUsernamesRepository, @@ -460,7 +454,6 @@ const $noteEditRepository: Provider = { $promoNotesRepository, $promoReadsRepository, $relaysRepository, - $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, @@ -495,7 +488,7 @@ const $noteEditRepository: Provider = { $userPublickeysRepository, $userListsRepository, $userListFavoritesRepository, - $userListJoiningsRepository, + $userListMembershipsRepository, $userNotePiningsRepository, $userIpsRepository, $usedUsernamesRepository, @@ -528,7 +521,6 @@ const $noteEditRepository: Provider = { $promoNotesRepository, $promoReadsRepository, $relaysRepository, - $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 8f0122a90..9650622dd 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -187,6 +187,11 @@ export class MiUser { }) public isExplorable: boolean; + @Column('boolean', { + default: false, + }) + public isHibernated: boolean; + // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ @Column('boolean', { default: false, diff --git a/packages/backend/src/models/UserListJoining.ts b/packages/backend/src/models/UserListMembership.ts similarity index 76% rename from packages/backend/src/models/UserListJoining.ts rename to packages/backend/src/models/UserListMembership.ts index 4918f2f70..f337f19a4 100644 --- a/packages/backend/src/models/UserListJoining.ts +++ b/packages/backend/src/models/UserListMembership.ts @@ -8,14 +8,14 @@ import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiUserList } from './UserList.js'; -@Entity('user_list_joining') +@Entity('user_list_membership') @Index(['userId', 'userListId'], { unique: true }) -export class MiUserListJoining { +export class MiUserListMembership { @PrimaryColumn(id()) public id: string; @Column('timestamp with time zone', { - comment: 'The created date of the UserListJoining.', + comment: 'The created date of the UserListMembership.', }) public createdAt: Date; @@ -44,4 +44,10 @@ export class MiUserListJoining { }) @JoinColumn() public userList: MiUserList | null; + + // タイムラインにその人のリプライまで含めるかどうか + @Column('boolean', { + default: false, + }) + public withReplies: boolean; } diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 09c2c6446..8520a09f0 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -8,6 +8,7 @@ import { obsoleteNotificationTypes, ffVisibility, notificationTypes } from '@/ty import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiPage } from './Page.js'; +import { MiUserList } from './UserList.js'; // TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも // ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン @@ -229,16 +230,25 @@ export class MiUserProfile { }) public mutedInstances: string[]; - @Column('enum', { - enum: [ - ...notificationTypes, - // マイグレーションで削除が困難なので古いenumは残しておく - ...obsoleteNotificationTypes, - ], - array: true, - default: [], + @Column('jsonb', { + default: {}, }) - public mutingNotificationTypes: typeof notificationTypes[number][]; + public notificationRecieveConfig: { + [notificationType in typeof notificationTypes[number]]?: { + type: 'all'; + } | { + type: 'never'; + } | { + type: 'following'; + } | { + type: 'follower'; + } | { + type: 'mutualFollow'; + } | { + type: 'list'; + userListId: MiUserList['id']; + }; + }; @Column('varchar', { length: 32, array: true, default: '{}', diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index ca047569c..b76f6d542 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -28,7 +28,6 @@ import { MiHashtag } from '@/models/Hashtag.js'; import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; -import { MiMutedNote } from '@/models/MutedNote.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; @@ -53,7 +52,7 @@ 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 { MiUserListJoining } from '@/models/UserListJoining.js'; +import { MiUserListMembership } from '@/models/UserListMembership.js'; import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserPending } from '@/models/UserPending.js'; import { MiUserProfile } from '@/models/UserProfile.js'; @@ -97,7 +96,6 @@ export { MiInstance, MiMeta, MiModerationLog, - MiMutedNote, MiMuting, MiRenoteMuting, MiNote, @@ -123,7 +121,7 @@ export { MiUserKeypair, MiUserList, MiUserListFavorite, - MiUserListJoining, + MiUserListMembership, MiUserNotePining, MiUserPending, MiUserProfile, @@ -165,7 +163,6 @@ export type HashtagsRepository = Repository; export type InstancesRepository = Repository; export type MetasRepository = Repository; export type ModerationLogsRepository = Repository; -export type MutedNotesRepository = Repository; export type MutingsRepository = Repository; export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; @@ -191,7 +188,7 @@ export type UserIpsRepository = Repository; export type UserKeypairsRepository = Repository; export type UserListsRepository = Repository; export type UserListFavoritesRepository = Repository; -export type UserListJoiningsRepository = Repository; +export type UserListMembershipsRepository = Repository; export type UserNotePiningsRepository = Repository; export type UserPendingsRepository = Repository; export type UserProfilesRepository = Repository; diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index f3c436d0b..ad0cb3c45 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -17,12 +17,12 @@ export const packedNoteSchema = { optional: false, nullable: false, format: 'date-time', }, - deletedAt: { + updatedAt: { type: 'string', optional: true, nullable: true, format: 'date-time', }, - updatedAt: { + deletedAt: { type: 'string', optional: true, nullable: true, format: 'date-time', @@ -147,7 +147,7 @@ export const packedNoteSchema = { isSensitive: { type: 'boolean', optional: true, nullable: false, - } + }, }, }, }, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 5112e680e..25f054728 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -283,6 +283,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'string', nullable: false, optional: true, }, + withReplies: { + type: 'boolean', + nullable: false, optional: true, + }, //#endregion }, } as const; @@ -393,13 +397,9 @@ export const packedMeDetailedOnlySchema = { nullable: false, optional: false, }, }, - mutingNotificationTypes: { - type: 'array', - nullable: true, optional: false, - items: { - type: 'string', - nullable: false, optional: false, - }, + notificationRecieveConfig: { + type: 'object', + nullable: false, optional: false, }, emailNotificationTypes: { type: 'array', diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index b12a84ac9..5cf9d7d1a 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -36,7 +36,6 @@ import { MiHashtag } from '@/models/Hashtag.js'; import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; -import { MiMutedNote } from '@/models/MutedNote.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; @@ -62,7 +61,7 @@ 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 { MiUserListJoining } from '@/models/UserListJoining.js'; +import { MiUserListMembership } from '@/models/UserListMembership.js'; import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserPending } from '@/models/UserPending.js'; import { MiUserProfile } from '@/models/UserProfile.js'; @@ -139,7 +138,7 @@ export const entities = [ MiUserPublickey, MiUserList, MiUserListFavorite, - MiUserListJoining, + MiUserListMembership, MiUserNotePining, MiUserSecurityKey, MiUsedUsername, @@ -175,7 +174,6 @@ export const entities = [ MiPromoNote, MiPromoRead, MiRelay, - MiMutedNote, MiChannel, MiChannelFollowing, MiChannelFavorite, diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index f0453f705..e252c5d8a 100644 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, LessThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js'; +import type { AntennasRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; @@ -25,9 +25,6 @@ export class CleanProcessorService { @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, - @Inject(DI.mutedNotesRepository) - private mutedNotesRepository: MutedNotesRepository, - @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, @@ -48,16 +45,6 @@ export class CleanProcessorService { createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), }); - this.mutedNotesRepository.delete({ - id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), - reason: 'word', - }); - - this.mutedNotesRepository.delete({ - id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), - reason: 'word', - }); - // 使われてないアンテナを停止 if (this.config.deactivateAntennaThreshold > 0) { this.antennasRepository.update({ diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index f941fb6e8..a0afbee3b 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { format as DateFormat } from 'date-fns'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, MiUser } from '@/models/_.js'; +import type { AntennasRepository, UsersRepository, UserListMembershipsRepository, MiUser } from '@/models/_.js'; import Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { bindThis } from '@/decorators.js'; @@ -29,8 +29,8 @@ export class ExportAntennasProcessorService { @Inject(DI.antennasRepository) private antennsRepository: AntennasRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private driveService: DriveService, private utilityService: UtilityService, @@ -65,9 +65,9 @@ export class ExportAntennasProcessorService { for (const [index, antenna] of antennas.entries()) { let users: MiUser[] | undefined; if (antenna.userListId !== null) { - const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId }); + const memberships = await this.userListMembershipsRepository.findBy({ userListId: antenna.userListId }); users = await this.usersRepository.findBy({ - id: In(joinings.map(j => j.userId)), + id: In(memberships.map(j => j.userId)), }); } write(JSON.stringify({ diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index 7baaa7081..a3f9441dc 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; import { format as dateFormat } from 'date-fns'; import { DI } from '@/di-symbols.js'; -import type { UserListJoiningsRepository, UserListsRepository, UsersRepository } from '@/models/_.js'; +import type { UserListMembershipsRepository, UserListsRepository, UsersRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; @@ -29,8 +29,8 @@ export class ExportUserListsProcessorService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private utilityService: UtilityService, private driveService: DriveService, @@ -61,9 +61,9 @@ export class ExportUserListsProcessorService { const stream = fs.createWriteStream(path, { flags: 'a' }); for (const list of lists) { - const joinings = await this.userListJoiningsRepository.findBy({ userListId: list.id }); + const memberships = await this.userListMembershipsRepository.findBy({ userListId: list.id }); const users = await this.usersRepository.findBy({ - id: In(joinings.map(j => j.userId)), + id: In(memberships.map(j => j.userId)), }); for (const u of users) { diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts index 54ca1a86d..9be36a9d0 100644 --- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, DriveFilesRepository, UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; +import type { UsersRepository, DriveFilesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; @@ -33,8 +33,8 @@ export class ImportUserListsProcessorService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private utilityService: UtilityService, private idService: IdService, @@ -99,9 +99,9 @@ export class ImportUserListsProcessorService { target = await this.remoteUserResolveService.resolveUser(username, host); } - if (await this.userListJoiningsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; + if (await this.userListMembershipsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; - this.userListService.push(target, list!, user); + this.userListService.addMember(target, list!, user); } catch (e) { this.logger.warn(`Error in line:${linenum} ${e}`); } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 15a2621da..74b26af27 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -205,7 +205,6 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; import * as ep___i_favorites from './endpoints/i/favorites.js'; import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; -import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js'; import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js'; @@ -337,7 +336,9 @@ import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; +import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; +import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; +import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_flashs from './endpoints/users/flashs.js'; @@ -555,7 +556,6 @@ const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default }; const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default }; const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default }; -const $i_getWordMutedNotesCount: Provider = { provide: 'ep:i/get-word-muted-notes-count', useClass: ep___i_getWordMutedNotesCount.default }; const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default }; const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default }; const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default }; @@ -687,7 +687,9 @@ const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default }; const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default }; -const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default }; +const $users_lists_createFromPublic: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_createFromPublic.default }; +const $users_lists_updateMembership: Provider = { provide: 'ep:users/lists/update-membership', useClass: ep___users_lists_updateMembership.default }; +const $users_lists_getMemberships: Provider = { provide: 'ep:users/lists/get-memberships', useClass: ep___users_lists_getMemberships.default }; const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default }; @@ -909,7 +911,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_favorites, $i_gallery_likes, $i_gallery_posts, - $i_getWordMutedNotesCount, $i_importBlocking, $i_importFollowing, $i_importMuting, @@ -1041,7 +1042,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_update, $users_lists_favorite, $users_lists_unfavorite, - $users_lists_create_from_public, + $users_lists_createFromPublic, + $users_lists_updateMembership, + $users_lists_getMemberships, $users_notes, $users_pages, $users_flashs, @@ -1257,7 +1260,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_favorites, $i_gallery_likes, $i_gallery_posts, - $i_getWordMutedNotesCount, $i_importBlocking, $i_importFollowing, $i_importMuting, @@ -1386,7 +1388,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_lists_update, $users_lists_favorite, $users_lists_unfavorite, - $users_lists_create_from_public, + $users_lists_createFromPublic, + $users_lists_updateMembership, + $users_lists_getMemberships, $users_notes, $users_pages, $users_flashs, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 9acaa688c..badcec1b3 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -14,6 +14,7 @@ import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; +import { UserService } from '@/core/UserService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/Connection.js'; import { ChannelsService } from './stream/ChannelsService.js'; @@ -37,6 +38,7 @@ export class StreamingApiServerService { private authenticateService: AuthenticateService, private channelsService: ChannelsService, private notificationService: NotificationService, + private usersService: UserService, ) { } @@ -130,14 +132,10 @@ export class StreamingApiServerService { this.#connections.set(connection, Date.now()); const userUpdateIntervalId = user ? setInterval(() => { - this.usersRepository.update(user.id, { - lastActiveDate: new Date(), - }); + this.usersService.updateLastActiveDate(user); }, 1000 * 60 * 5) : null; if (user) { - this.usersRepository.update(user.id, { - lastActiveDate: new Date(), - }); + this.usersService.updateLastActiveDate(user); } connection.once('close', () => { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 5ea412191..279d56d1e 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -205,7 +205,6 @@ import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; import * as ep___i_favorites from './endpoints/i/favorites.js'; import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; -import * as ep___i_getWordMutedNotesCount from './endpoints/i/get-word-muted-notes-count.js'; import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; import * as ep___i_importFollowing from './endpoints/i/import-following.js'; import * as ep___i_importMuting from './endpoints/i/import-muting.js'; @@ -336,8 +335,10 @@ import * as ep___users_lists_push from './endpoints/users/lists/push.js'; import * as ep___users_lists_show from './endpoints/users/lists/show.js'; import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js'; +import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; import * as ep___users_lists_update from './endpoints/users/lists/update.js'; +import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; +import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; import * as ep___users_notes from './endpoints/users/notes.js'; import * as ep___users_pages from './endpoints/users/pages.js'; import * as ep___users_flashs from './endpoints/users/flashs.js'; @@ -553,7 +554,6 @@ const eps = [ ['i/favorites', ep___i_favorites], ['i/gallery/likes', ep___i_gallery_likes], ['i/gallery/posts', ep___i_gallery_posts], - ['i/get-word-muted-notes-count', ep___i_getWordMutedNotesCount], ['i/import-blocking', ep___i_importBlocking], ['i/import-following', ep___i_importFollowing], ['i/import-muting', ep___i_importMuting], @@ -685,7 +685,9 @@ const eps = [ ['users/lists/favorite', ep___users_lists_favorite], ['users/lists/unfavorite', ep___users_lists_unfavorite], ['users/lists/update', ep___users_lists_update], - ['users/lists/create-from-public', ep___users_lists_create_from_public], + ['users/lists/create-from-public', ep___users_lists_createFromPublic], + ['users/lists/update-membership', ep___users_lists_updateMembership], + ['users/lists/get-memberships', ep___users_lists_getMemberships], ['users/notes', ep___users_notes], ['users/pages', ep___users_pages], ['users/flashs', ep___users_flashs], diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts index a13d08fd3..e48dffecf 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { AdsRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -39,9 +40,10 @@ export default class extends Endpoint { // eslint- private adsRepository: AdsRepository, private idService: IdService, + private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { - await this.adsRepository.insert({ + const ad = await this.adsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), expiresAt: new Date(ps.expiresAt), @@ -53,7 +55,14 @@ export default class extends Endpoint { // eslint- ratio: ps.ratio, place: ps.place, memo: ps.memo, + }).then(r => this.adsRepository.findOneByOrFail({ id: r.identifiers[0].id })); + + this.moderationLogService.log(me, 'createAd', { + adId: ad.id, + ad: ad, }); + + return ad; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/ad/delete.ts b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts index d3c53d4f6..8097133a4 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { AdsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -37,6 +38,8 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.adsRepository) private adsRepository: AdsRepository, + + private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { const ad = await this.adsRepository.findOneBy({ id: ps.id }); @@ -44,6 +47,11 @@ export default class extends Endpoint { // eslint- if (ad == null) throw new ApiError(meta.errors.noSuchAd); await this.adsRepository.delete(ad.id); + + this.moderationLogService.log(me, 'deleteAd', { + adId: ad.id, + ad: ad, + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts index adff3ed0a..29eff8952 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -22,6 +22,7 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + publishing: { type: 'boolean', default: false }, }, required: [], } as const; @@ -36,6 +37,9 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId); + if (ps.publishing) { + query.andWhere('ad.expiresAt > :now', { now: new Date() }).andWhere('ad.startsAt <= :now', { now: new Date() }); + } const ads = await query.limit(ps.limit).getMany(); return ads; diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts index 5b77f67e1..d065f9ec5 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { AdsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -46,6 +47,8 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.adsRepository) private adsRepository: AdsRepository, + + private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { const ad = await this.adsRepository.findOneBy({ id: ps.id }); @@ -63,6 +66,14 @@ export default class extends Endpoint { // eslint- startsAt: new Date(ps.startsAt), dayOfWeek: ps.dayOfWeek, }); + + const updatedAd = await this.adsRepository.findOneByOrFail({ id: ad.id }); + + this.moderationLogService.log(me, 'updateAd', { + adId: ad.id, + before: ad, + after: updatedAd, + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index e065b99e9..345459753 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -81,7 +81,7 @@ export default class extends Endpoint { // eslint- receiveAnnouncementEmail: profile.receiveAnnouncementEmail, mutedWords: profile.mutedWords, mutedInstances: profile.mutedInstances, - mutingNotificationTypes: profile.mutingNotificationTypes, + notificationRecieveConfig: profile.notificationRecieveConfig, isModerator: isModerator, isSilenced: isSilenced, isSuspended: user.isSuspended, diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index eaae7bff6..63e542cb6 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -56,8 +56,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -86,7 +86,7 @@ export default class extends Endpoint { // eslint- }); const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const noteIdsRes = await this.redisClient.xrevrange( + const noteIdsRes = await this.redisForTimelines.xrevrange( `antennaTimeline:${antenna.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 026b64953..56b8fc5c3 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -54,8 +54,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -83,7 +83,7 @@ export default class extends Endpoint { // eslint- let noteIdsRes: [string, string[]][] = []; if (!ps.sinceId && !ps.sinceDate) { - noteIdsRes = await this.redisClient.xrevrange( + noteIdsRes = await this.redisForTimelines.xrevrange( `channelTimeline:${channel.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', '-', @@ -104,7 +104,6 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); } //#endregion @@ -129,7 +128,6 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); } //#endregion diff --git a/packages/backend/src/server/api/endpoints/following/update.ts b/packages/backend/src/server/api/endpoints/following/update.ts index 25f393e51..db17d151d 100644 --- a/packages/backend/src/server/api/endpoints/following/update.ts +++ b/packages/backend/src/server/api/endpoints/following/update.ts @@ -57,8 +57,9 @@ export const paramDef = { properties: { userId: { type: 'string', format: 'misskey:id' }, notify: { type: 'string', enum: ['normal', 'none'] }, + withReplies: { type: 'boolean' }, }, - required: ['userId', 'notify'], + required: ['userId'], } as const; @Injectable() @@ -98,7 +99,8 @@ export default class extends Endpoint { // eslint- await this.followingsRepository.update({ id: exist.id, }, { - notify: ps.notify === 'none' ? null : ps.notify, + notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined, + withReplies: ps.withReplies != null ? ps.withReplies : undefined, }); return await this.userEntityService.pack(follower.id, me); diff --git a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts deleted file mode 100644 index d62bfbb3e..000000000 --- a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { MutedNotesRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['account'], - - requireCredential: true, - - kind: 'read:account', - - res: { - type: 'object', - optional: false, nullable: false, - properties: { - count: { - type: 'number', - optional: false, nullable: false, - }, - }, - }, -} 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( - @Inject(DI.mutedNotesRepository) - private mutedNotesRepository: MutedNotesRepository, - ) { - super(meta, paramDef, async (ps, me) => { - return { - count: await this.mutedNotesRepository.countBy({ - userId: me.id, - reason: 'word', - }), - }; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 204906370..e5bf27d22 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -166,9 +166,7 @@ export const paramDef = { mutedInstances: { type: 'array', items: { type: 'string', } }, - mutingNotificationTypes: { type: 'array', items: { - type: 'string', enum: notificationTypes, - } }, + notificationRecieveConfig: { type: 'object' }, emailNotificationTypes: { type: 'array', items: { type: 'string', } }, @@ -250,7 +248,7 @@ export default class extends Endpoint { // eslint- profileUpdates.enableWordMute = ps.mutedWords.length > 0; } if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; - if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; + if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts index 6d34aaccf..bfb024bcf 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.test.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts @@ -34,10 +34,11 @@ describe('api:notes/create', () => { .toBe(VALID); }); - test('null post', () => { - expect(v({ text: null })) - .toBe(INVALID); - }); + // TODO + //test('null post', () => { + // expect(v({ text: null })) + // .toBe(INVALID); + //}); test('0 characters post', () => { expect(v({ text: '' })) diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 2e4d316c4..37a0525e2 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -118,7 +118,7 @@ export const paramDef = { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, - nullable: false, + nullable: true, }, fileIds: { type: 'array', diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 8784e8615..e5a86905d 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -40,7 +40,6 @@ export const paramDef = { type: 'object', properties: { withFiles: { type: 'boolean', default: false }, - withReplies: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, @@ -68,49 +67,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.gtlDisabled); } - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.visibility = \'public\'') - .andWhere('note.channelId IS NULL') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - this.queryService.generateRepliesQuery(query, ps.withReplies, me); - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } - //#endregion - - const timeline = await query.limit(ps.limit).getMany(); - - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); - - return await this.noteEntityService.packMany(timeline, me); + // TODO? + return []; }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 9bde5dee2..d6ed3db6e 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,14 +5,16 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -51,7 +53,6 @@ export const paramDef = { includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, - withReplies: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, }, required: [], @@ -60,17 +61,17 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - private noteEntityService: NoteEntityService, - private queryService: QueryService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, private idService: IdService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me.id); @@ -78,79 +79,75 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.stlDisabled); } - //#region Construct query - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + const [ + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで - .andWhere(new Brackets(qb => { - qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }) - .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - })) + let timeline: MiNote[] = []; + + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let htlNoteIdsRes: [string, string[]][] = []; + let ltlNoteIdsRes: [string, string[]][] = []; + + if (!ps.sinceId && !ps.sinceDate) { + htlNoteIdsRes = await this.redisForTimelines.xrevrange( + ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + '-', + 'COUNT', limit); + ltlNoteIdsRes = await this.redisForTimelines.xrevrange( + ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + '-', + 'COUNT', limit); + } + + const htlNoteIds = htlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + const ltlNoteIds = ltlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); + noteIds.sort((a, b) => a > b ? -1 : 1); + noteIds = noteIds.slice(0, ps.limit); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .setParameters(followingQuery.getParameters()); + .leftJoinAndSelect('note.channel', 'channel'); - this.queryService.generateChannelQuery(query, me); - this.queryService.generateRepliesQuery(query, ps.withReplies, me); - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + timeline = await query.getMany(); - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + timeline = timeline.filter(note => { + if (note.userId === me.id) { + return true; + } + if (isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } + } - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + return true; + }); - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + // TODO: フィルタした結果件数が足りなかった場合の対応 - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } - //#endregion - - const timeline = await query.limit(ps.limit).getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); process.nextTick(() => { this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 0fefddc51..ed57ca1a3 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -5,14 +5,16 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { MiNote, NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -41,11 +43,7 @@ export const paramDef = { type: 'object', properties: { withFiles: { type: 'boolean', default: false }, - withReplies: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, - fileType: { type: 'array', items: { - type: 'string', - } }, excludeNsfw: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, @@ -59,14 +57,17 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, private noteEntityService: NoteEntityService, - private queryService: QueryService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, private idService: IdService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me ? me.id : null); @@ -74,56 +75,63 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.ltlDisabled); } - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで - .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') + const [ + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = me ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]) : [new Set(), new Set(), new Set()]; + + let timeline: MiNote[] = []; + + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; + + if (!ps.sinceId && !ps.sinceDate) { + noteIdsRes = await this.redisForTimelines.xrevrange( + ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + '-', + 'COUNT', limit); + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - this.queryService.generateChannelQuery(query, me); - this.queryService.generateRepliesQuery(query, ps.withReplies, me); - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateMutedNoteQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + timeline = await query.getMany(); - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.fileType != null) { - query.andWhere('note.fileIds != \'{}\''); - query.andWhere(new Brackets(qb => { - for (const type of ps.fileType!) { - const i = ps.fileType!.indexOf(type); - qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); - } - })); - - if (ps.excludeNsfw) { - query.andWhere('note.cw IS NULL'); - query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + timeline = timeline.filter(note => { + if (me && (note.userId === me.id)) { + return true; + } + if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } } - } - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } - //#endregion + return true; + }); - const timeline = await query.limit(ps.limit).getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); process.nextTick(() => { if (me) { diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 0d47cc170..2f25d2d7c 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,13 +5,16 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; export const meta = { tags: ['notes'], @@ -41,7 +44,6 @@ export const paramDef = { includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, - withReplies: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, }, required: [], @@ -50,96 +52,82 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - private noteEntityService: NoteEntityService, - private queryService: QueryService, private activeUsersChart: ActiveUsersChart, private idService: IdService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { - const followees = await this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }) - .getMany(); + const [ + followings, + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]); - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - // パフォーマンス上の利点が無さそう? - //.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで + let timeline: MiNote[] = []; + + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; + + if (!ps.sinceId && !ps.sinceDate) { + noteIdsRes = await this.redisForTimelines.xrevrange( + ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + '-', + 'COUNT', limit); + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + timeline = await query.getMany(); - query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - } else { - query.andWhere('note.userId = :meId', { meId: me.id }); - } + timeline = timeline.filter(note => { + if (note.userId === me.id) { + return true; + } + if (isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } + } + if (note.reply && note.reply.visibility === 'followers') { + if (!Object.hasOwn(followings, note.reply.userId)) return false; + } - this.queryService.generateChannelQuery(query, me); - this.queryService.generateRepliesQuery(query, ps.withReplies, me); - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + return true; + }); - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + // TODO: フィルタした結果件数が足りなかった場合の対応 - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } - //#endregion - - const timeline = await query.limit(ps.limit).getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); process.nextTick(() => { this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 00cb9a0a2..a1561c944 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -10,12 +10,13 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], - requireCredential: false, + requireCredential: true, res: { type: 'object', @@ -23,6 +24,11 @@ export const meta = { }, errors: { + unavailable: { + message: 'Translate of notes unavailable.', + code: 'UNAVAILABLE', + id: '50a70314-2d8a-431b-b433-efa5cc56444c', + }, noSuchNote: { message: 'No such note.', code: 'NO_SUCH_NOTE', @@ -47,14 +53,20 @@ export default class extends Endpoint { // eslint- private getterService: GetterService, private metaService: MetaService, private httpRequestService: HttpRequestService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { + const policies = await this.roleService.getUserPolicies(me.id); + if (!policies.canUseTranslator) { + throw new ApiError(meta.errors.unavailable); + } + const note = await this.getterService.getNote(ps.noteId).catch(err => { if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); throw err; }); - if (!(await this.noteEntityService.isVisibleForMe(note, me ? me.id : null))) { + if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) { return 204; // TODO: 良い感じのエラー返す } diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index c20274b2b..8e943826d 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -5,12 +5,16 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { NotesRepository, UserListsRepository, UserListMembershipsRepository, MiNote } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { IdService } from '@/core/IdService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -63,18 +67,19 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, - private noteEntityService: NoteEntityService, - private queryService: QueryService, private activeUsersChart: ActiveUsersChart, + private cacheService: CacheService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const list = await this.userListsRepository.findOneBy({ @@ -86,72 +91,65 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchList); } - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId') + const [ + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]); + + let timeline: MiNote[] = []; + + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; + + if (!ps.sinceId && !ps.sinceDate) { + noteIdsRes = await this.redisForTimelines.xrevrange( + ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + '-', + 'COUNT', limit); + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser') - .andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); + .leftJoinAndSelect('note.channel', 'channel'); - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + timeline = await query.getMany(); - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + timeline = timeline.filter(note => { + if (note.userId === me.id) { + return true; + } + if (isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } + } - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + return true; + }); - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + // TODO: フィルタした結果件数が足りなかった場合の対応 - if (!ps.withReplies) { - query.andWhere('note.replyId IS NULL'); - } - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion - - const timeline = await query.limit(ps.limit).getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 6dc35907e..f2533efa3 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -53,8 +53,8 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -79,7 +79,7 @@ export default class extends Endpoint { // eslint- return []; } const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const noteIdsRes = await this.redisClient.xrevrange( + const noteIdsRes = await this.redisForTimelines.xrevrange( `roleTimeline:${role.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts index fd1bb48a4..f2f6c4303 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js'; +import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import type { MiUserList } from '@/models/UserList.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -76,8 +76,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -110,7 +110,7 @@ export default class extends Endpoint { // eslint- name: ps.name, } as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); - const users = (await this.userListJoiningsRepository.findBy({ + const users = (await this.userListMembershipsRepository.findBy({ userListId: ps.listId, })).map(x => x.userId); @@ -132,7 +132,7 @@ export default class extends Endpoint { // eslint- } } - const exist = await this.userListJoiningsRepository.exist({ + const exist = await this.userListMembershipsRepository.exist({ where: { userListId: userList.id, userId: currentUser.id, @@ -144,7 +144,7 @@ export default class extends Endpoint { // eslint- } try { - await this.userListService.push(currentUser, userList, me); + await this.userListService.addMember(currentUser, userList, me); } catch (err) { if (err instanceof UserListService.TooManyUsersError) { throw new ApiError(meta.errors.tooManyUsers); diff --git a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts new file mode 100644 index 000000000..ae8b4e9b8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { UserListsRepository, UserListFavoritesRepository, UserListMembershipsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['lists', 'account'], + + requireCredential: false, + + kind: 'read:account', + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + listId: { type: 'string', format: 'misskey:id' }, + forPublic: { type: 'boolean', default: false }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: ['listId'], +} as const; + +@Injectable() // eslint-disable-next-line import/no-default-export +export default class extends Endpoint { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + + private userListEntityService: UserListEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? { + id: ps.listId, + userId: me.id, + } : { + id: ps.listId, + isPublic: true, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId) + .andWhere('membership.userListId = :userListId', { userListId: userList.id }) + .innerJoinAndSelect('membership.user', 'user'); + + const memberships = await query + .limit(ps.limit) + .getMany(); + + return this.userListEntityService.packMembershipsMany(memberships); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index 0b0106174..e90122224 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -4,12 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; +import type { UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; +import { UserListService } from '@/core/UserListService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -53,12 +52,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, - - private userEntityService: UserEntityService, + private userListService: UserListService, private getterService: GetterService, - private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { // Fetch the list @@ -77,10 +72,7 @@ export default class extends Endpoint { // eslint- throw err; }); - // Pull the user - await this.userListJoiningsRepository.delete({ userListId: userList.id, userId: user.id }); - - this.globalEventService.publishUserListStream(userList.id, 'userRemoved', await this.userEntityService.pack(user)); + await this.userListService.removeMember(user, userList); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 9bb1a71f5..c4ceec575 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js'; +import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { UserListService } from '@/core/UserListService.js'; @@ -76,8 +76,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -115,7 +115,7 @@ export default class extends Endpoint { // eslint- } } - const exist = await this.userListJoiningsRepository.exist({ + const exist = await this.userListMembershipsRepository.exist({ where: { userListId: userList.id, userId: user.id, @@ -127,7 +127,7 @@ export default class extends Endpoint { // eslint- } try { - await this.userListService.push(user, userList, me); + await this.userListService.addMember(user, userList, me); } catch (err) { if (err instanceof UserListService.TooManyUsersError) { throw new ApiError(meta.errors.tooManyUsers); diff --git a/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts new file mode 100644 index 000000000..b69465b94 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { UserListsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { UserListService } from '@/core/UserListService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['lists', 'users'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:account', + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '588e7f72-c744-4a61-b180-d354e912bda2', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + listId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + withReplies: { type: 'boolean' }, + }, + required: ['listId', 'userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + private userListService: UserListService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await this.userListsRepository.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + await this.userListService.updateMembership(user, userList, { + withReplies: ps.withReplies, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index e660a0bb2..9321a41d5 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -5,12 +5,14 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import * as Redis from 'ioredis'; +import type { MiNote, NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -50,9 +52,6 @@ export const paramDef = { untilDate: { type: 'integer' }, includeMyRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, - fileType: { type: 'array', items: { - type: 'string', - } }, excludeNsfw: { type: 'boolean', default: false }, }, required: ['userId'], @@ -61,64 +60,52 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, private noteEntityService: NoteEntityService, - private queryService: QueryService, private getterService: GetterService, + private cacheService: CacheService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - // Lookup user - const user = await this.getterService.getUser(ps.userId).catch(err => { - if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); - throw err; - }); + let timeline: MiNote[] = []; - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.userId = :userId', { userId: user.id }) + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; + + if (!ps.sinceId && !ps.sinceDate) { + noteIdsRes = await this.redisForTimelines.xrevrange( + ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : ps.withReplies ? `userTimelineWithReplies:${ps.userId}` : `userTimeline:${ps.userId}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + '-', + 'COUNT', limit); + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false; + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('note.channel', 'channel') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('channel.isSensitive = false'); - })); - - this.queryService.generateVisibilityQuery(query, me); - if (me) { - this.queryService.generateMutedUserQuery(query, me, user); - this.queryService.generateBlockedUserQuery(query, me); - } - - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - - if (ps.fileType != null) { - query.andWhere('note.fileIds != \'{}\''); - query.andWhere(new Brackets(qb => { - for (const type of ps.fileType!) { - const i = ps.fileType!.indexOf(type); - qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); - } - })); - - if (ps.excludeNsfw) { - query.andWhere('note.cw IS NULL'); - query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); - } - } + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); if (!ps.withReplies) { query.andWhere('note.replyId IS NULL'); } - + if (ps.withRenotes === false) { query.andWhere(new Brackets(qb => { qb.orWhere('note.renoteId IS NULL'); @@ -129,19 +116,21 @@ export default class extends Endpoint { // eslint- })); } - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :userId', { userId: user.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + timeline = await query.getMany(); - //#endregion + timeline = timeline.filter(note => { + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (ps.withRenotes === false) return false; + } + } - const timeline = await query.limit(ps.limit).getMany(); + if (note.visibility === 'followers' && !isFollowing) return false; + + return true; + }); + + timeline.sort((a, b) => a.id > b.id ? -1 : 1); return await this.noteEntityService.packMany(timeline, me); }); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index fd91681fc..f981e6387 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -11,11 +11,11 @@ import type { NoteReadService } from '@/core/NoteReadService.js'; import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; -import { MiUserProfile } from '@/models/_.js'; +import { MiFollowing, MiUserProfile } from '@/models/_.js'; +import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; -import type { StreamEventEmitter, StreamMessages } from './types.js'; /** * Main stream connection @@ -30,7 +30,7 @@ export default class Connection { private subscribingNotes: any = {}; private cachedNotes: Packed<'Note'>[] = []; public userProfile: MiUserProfile | null = null; - public following: Set = new Set(); + public following: Record | undefined> = {}; public followingChannels: Set = new Set(); public userIdsWhoMeMuting: Set = new Set(); public userIdsWhoBlockingMe: Set = new Set(); @@ -122,7 +122,7 @@ export default class Connection { } @bindThis - private onBroadcastMessage(data: StreamMessages['broadcast']['payload']) { + private onBroadcastMessage(data: GlobalEvents['broadcast']['payload']) { this.sendMessageToWs(data.type, data.body); } @@ -196,7 +196,7 @@ export default class Connection { } @bindThis - private async onNoteStreamMessage(data: StreamMessages['note']['payload']) { + private async onNoteStreamMessage(data: GlobalEvents['note']['payload']) { this.sendMessageToWs('noteUpdated', { id: data.body.id, type: data.type, diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 87648a3a7..a48e6ba5c 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -7,8 +7,8 @@ import { Injectable } from '@nestjs/common'; import { isUserRelated } from '@/misc/is-user-related.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import Channel from '../channel.js'; -import type { StreamMessages } from '../types.js'; class AntennaChannel extends Channel { public readonly chName = 'antenna'; @@ -35,7 +35,7 @@ class AntennaChannel extends Channel { } @bindThis - private async onEvent(data: StreamMessages['antenna']['payload']) { + private async onEvent(data: GlobalEvents['antenna']['payload']) { if (data.type === 'note') { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index fef52b685..f0ac50349 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -18,7 +18,6 @@ class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; public static shouldShare = true; public static requireCredential = false; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -38,7 +37,6 @@ class GlobalTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.gtlAvailable) return; - this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; // Subscribe events @@ -64,7 +62,7 @@ class GlobalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.withReplies) { + if (note.reply && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; @@ -82,13 +80,6 @@ class GlobalTimelineChannel extends Channel { if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - // 流れてきたNoteがミュートすべきNoteだったら無視する - // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) - // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 - // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 - // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる - if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; - this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 198c68e1c..1c1b1c2ae 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -16,7 +16,6 @@ class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; public static shouldShare = true; public static requireCredential = true; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -31,7 +30,6 @@ class HomeTimelineChannel extends Channel { @bindThis public async init(params: any) { - this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; this.subscriber.on('notesStream', this.onNote); @@ -43,7 +41,7 @@ class HomeTimelineChannel extends Channel { if (!this.followingChannels.has(note.channelId)) return; } else { // その投稿のユーザーをフォローしていなかったら弾く - if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return; + if ((this.user!.id !== note.userId) && !Object.hasOwn(this.following, note.userId)) return; } // Ignore notes from instances the user has muted @@ -73,7 +71,7 @@ class HomeTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.withReplies) { + if (note.reply && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; @@ -88,13 +86,6 @@ class HomeTimelineChannel extends Channel { if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - // 流れてきたNoteがミュートすべきNoteだったら無視する - // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) - // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 - // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 - // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる - if (await checkWordMute(note, this.user, this.userProfile!.mutedWords)) return; - this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index cde429747..e2f4817bf 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -18,7 +18,6 @@ class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; public static shouldShare = true; public static requireCredential = true; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -38,7 +37,6 @@ class HybridTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; - this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; // Subscribe events @@ -53,7 +51,7 @@ class HybridTimelineChannel extends Channel { // フォローしているチャンネルの投稿 の場合だけ if (!( (note.channelId == null && this.user!.id === note.userId) || - (note.channelId == null && this.following.has(note.userId)) || + (note.channelId == null && Object.hasOwn(this.following, note.userId)) || (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || (note.channelId != null && this.followingChannels.has(note.channelId)) )) return; @@ -85,7 +83,7 @@ class HybridTimelineChannel extends Channel { if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances ?? []))) return; // 関係ない返信は除外 - if (note.reply && !this.withReplies) { + if (note.reply && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; @@ -100,13 +98,6 @@ class HybridTimelineChannel extends Channel { if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - // 流れてきたNoteがミュートすべきNoteだったら無視する - // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) - // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 - // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 - // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる - if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; - this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index ef708c4fe..ca563b5d1 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -17,7 +17,6 @@ class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; public static shouldShare = true; public static requireCredential = false; - private withReplies: boolean; private withRenotes: boolean; constructor( @@ -37,7 +36,6 @@ class LocalTimelineChannel extends Channel { const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); if (!policies.ltlAvailable) return; - this.withReplies = params.withReplies ?? false; this.withRenotes = params.withRenotes ?? true; // Subscribe events @@ -64,7 +62,7 @@ class LocalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && this.user && !this.withReplies) { + if (note.reply && this.user && !this.following[note.userId]?.withReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; @@ -79,13 +77,6 @@ class LocalTimelineChannel extends Channel { if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - // 流れてきたNoteがミュートすべきNoteだったら無視する - // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) - // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 - // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 - // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる - if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; - this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index 76b587534..38d3604cc 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -9,8 +9,8 @@ import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import Channel from '../channel.js'; -import { StreamMessages } from '../types.js'; class RoleTimelineChannel extends Channel { public readonly chName = 'roleTimeline'; @@ -37,7 +37,7 @@ class RoleTimelineChannel extends Channel { } @bindThis - private async onEvent(data: StreamMessages['roleTimeline']['payload']) { + private async onEvent(data: GlobalEvents['roleTimeline']['payload']) { if (data.type === 'note') { const note = data.body; diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 8bbba0b6d..03f7760d8 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; +import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -18,12 +18,12 @@ class UserListChannel extends Channel { public static shouldShare = false; public static requireCredential = false; private listId: string; - public listUsers: MiUser['id'][] = []; + public membershipsMap: Record | undefined> = {}; private listUsersClock: NodeJS.Timeout; constructor( private userListsRepository: UserListsRepository, - private userListJoiningsRepository: UserListJoiningsRepository, + private userListMembershipsRepository: UserListMembershipsRepository, private noteEntityService: NoteEntityService, id: string, @@ -58,19 +58,25 @@ class UserListChannel extends Channel { @bindThis private async updateListUsers() { - const users = await this.userListJoiningsRepository.find({ + const memberships = await this.userListMembershipsRepository.find({ where: { userListId: this.listId, }, select: ['userId'], }); - this.listUsers = users.map(x => x.userId); + const membershipsMap: Record | undefined> = {}; + for (const membership of memberships) { + membershipsMap[membership.userId] = { + withReplies: membership.withReplies, + }; + } + this.membershipsMap = membershipsMap; } @bindThis private async onNote(note: Packed<'Note'>) { - if (!this.listUsers.includes(note.userId)) return; + if (!Object.hasOwn(this.membershipsMap, note.userId)) return; if (['followers', 'specified'].includes(note.visibility)) { note = await this.noteEntityService.pack(note.id, this.user, { @@ -95,6 +101,13 @@ class UserListChannel extends Channel { } } + // 関係ない返信は除外 + if (note.reply && !this.membershipsMap[note.userId]?.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する @@ -124,8 +137,8 @@ export class UserListChannelService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private noteEntityService: NoteEntityService, ) { @@ -135,7 +148,7 @@ export class UserListChannelService { public create(id: string, connection: Channel['connection']): UserListChannel { return new UserListChannel( this.userListsRepository, - this.userListJoiningsRepository, + this.userListMembershipsRepository, this.noteEntityService, id, connection, diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts deleted file mode 100644 index 63aff64fc..000000000 --- a/packages/backend/src/server/api/stream/types.ts +++ /dev/null @@ -1,258 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MiChannel } from '@/models/Channel.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiUserProfile } from '@/models/UserProfile.js'; -import type { MiNote } from '@/models/Note.js'; -import type { MiAntenna } from '@/models/Antenna.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiDriveFolder } from '@/models/DriveFolder.js'; -import type { MiUserList } from '@/models/UserList.js'; -import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; -import type { MiSignin } from '@/models/Signin.js'; -import type { MiPage } from '@/models/Page.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiWebhook } from '@/models/Webhook.js'; -import type { MiMeta } from '@/models/Meta.js'; -import { MiRole, MiRoleAssignment } from '@/models/_.js'; -import type Emitter from 'strict-event-emitter-types'; -import type { EventEmitter } from 'events'; - -//#region Stream type-body definitions -export interface InternalStreamTypes { - userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; - userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; }; - remoteUserUpdated: { id: MiUser['id']; }; - follow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; - unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; - blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; - blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; - policiesUpdated: MiRole['policies']; - roleCreated: MiRole; - roleDeleted: MiRole; - roleUpdated: MiRole; - userRoleAssigned: MiRoleAssignment; - userRoleUnassigned: MiRoleAssignment; - webhookCreated: MiWebhook; - webhookDeleted: MiWebhook; - webhookUpdated: MiWebhook; - antennaCreated: MiAntenna; - antennaDeleted: MiAntenna; - antennaUpdated: MiAntenna; - metaUpdated: MiMeta; - followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; - unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; - updateUserProfile: MiUserProfile; - mute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; - unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; -} - -export interface BroadcastTypes { - emojiAdded: { - emoji: Packed<'EmojiDetailed'>; - }; - emojiUpdated: { - emojis: Packed<'EmojiDetailed'>[]; - }; - emojiDeleted: { - emojis: { - id?: string; - name: string; - [other: string]: any; - }[]; - }; - announcementCreated: { - announcement: Packed<'Announcement'>; - }; -} - -export interface MainStreamTypes { - notification: Packed<'Notification'>; - mention: Packed<'Note'>; - reply: Packed<'Note'>; - renote: Packed<'Note'>; - follow: Packed<'UserDetailedNotMe'>; - followed: Packed<'User'>; - unfollow: Packed<'User'>; - meUpdated: Packed<'User'>; - pageEvent: { - pageId: MiPage['id']; - event: string; - var: any; - userId: MiUser['id']; - user: Packed<'User'>; - }; - urlUploadFinished: { - marker?: string | null; - file: Packed<'DriveFile'>; - }; - readAllNotifications: undefined; - unreadNotification: Packed<'Notification'>; - unreadMention: MiNote['id']; - readAllUnreadMentions: undefined; - unreadSpecifiedNote: MiNote['id']; - readAllUnreadSpecifiedNotes: undefined; - readAllAntennas: undefined; - unreadAntenna: MiAntenna; - readAllAnnouncements: undefined; - myTokenRegenerated: undefined; - signin: MiSignin; - registryUpdated: { - scope?: string[]; - key: string; - value: any | null; - }; - driveFileCreated: Packed<'DriveFile'>; - readAntenna: MiAntenna; - receiveFollowRequest: Packed<'User'>; - announcementCreated: { - announcement: Packed<'Announcement'>; - }; -} - -export interface DriveStreamTypes { - fileCreated: Packed<'DriveFile'>; - fileDeleted: MiDriveFile['id']; - fileUpdated: Packed<'DriveFile'>; - folderCreated: Packed<'DriveFolder'>; - folderDeleted: MiDriveFolder['id']; - folderUpdated: Packed<'DriveFolder'>; -} - -export interface NoteStreamTypes { - pollVoted: { - choice: number; - userId: MiUser['id']; - }; - deleted: { - deletedAt: Date; - }; - reacted: { - reaction: string; - emoji?: { - name: string; - url: string; - } | null; - userId: MiUser['id']; - }; - unreacted: { - reaction: string; - userId: MiUser['id']; - }; - updated: { - updatedAt: Date; - }; -} -type NoteStreamEventTypes = { - [key in keyof NoteStreamTypes]: { - id: MiNote['id']; - body: NoteStreamTypes[key]; - }; -}; - -export interface UserListStreamTypes { - userAdded: Packed<'User'>; - userRemoved: Packed<'User'>; -} - -export interface AntennaStreamTypes { - note: MiNote; -} - -export interface RoleTimelineStreamTypes { - note: Packed<'Note'>; -} - -export interface AdminStreamTypes { - newAbuseUserReport: { - id: MiAbuseUserReport['id']; - targetUserId: MiUser['id'], - reporterId: MiUser['id'], - comment: string; - }; -} -//#endregion - -// 辞書(interface or type)から{ type, body }ユニオンを定義 -// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type -// VS Codeの展開を防止するためにEvents型を定義 -type Events = { [K in keyof T]: { type: K; body: T[K]; } }; -type EventUnionFromDictionary< - T extends object, - U = Events -> = U[keyof U]; - -// redis通すとDateのインスタンスはstringに変換されるので -export type Serialized = { - [K in keyof T]: - T[K] extends Date - ? string - : T[K] extends (Date | null) - ? (string | null) - : T[K] extends Record - ? Serialized - : T[K]; -}; - -type SerializedAll = { - [K in keyof T]: Serialized; -}; - -// name/messages(spec) pairs dictionary -export type StreamMessages = { - internal: { - name: 'internal'; - payload: EventUnionFromDictionary>; - }; - broadcast: { - name: 'broadcast'; - payload: EventUnionFromDictionary>; - }; - main: { - name: `mainStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; - }; - drive: { - name: `driveStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; - }; - note: { - name: `noteStream:${MiNote['id']}`; - payload: EventUnionFromDictionary>; - }; - userList: { - name: `userListStream:${MiUserList['id']}`; - payload: EventUnionFromDictionary>; - }; - roleTimeline: { - name: `roleTimelineStream:${MiRole['id']}`; - payload: EventUnionFromDictionary>; - }; - antenna: { - name: `antennaStream:${MiAntenna['id']}`; - payload: EventUnionFromDictionary>; - }; - admin: { - name: `adminStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; - }; - notes: { - name: 'notesStream'; - payload: Serialized>; - }; -}; - -// API event definitions -// ストリームごとのEmitterの辞書を用意 -type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter.default void }> }; -// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; -// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする -export type StreamEventEmitter = UnionToIntersection; -// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる - -// provide stream channels union -export type StreamChannels = StreamMessages[keyof StreamMessages]['name']; diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 653c8c910..15d9cb944 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -188,7 +188,7 @@ export class ClientServerService { // Authenticate fastify.addHook('onRequest', async (request, reply) => { // %71ueueとかでリクエストされたら困るため - const url = decodeURI(request.url); + const url = decodeURI(request.routeOptions.url); if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) { const token = request.cookies.token; if (token == null) { @@ -728,8 +728,8 @@ export class ClientServerService { fastify.setErrorHandler(async (error, request, reply) => { const errId = randomUUID(); - this.clientLoggerService.logger.error(`Internal error occurred in ${request.routerPath}: ${error.message}`, { - path: request.routerPath, + this.clientLoggerService.logger.error(`Internal error occurred in ${request.routeOptions.url}: ${error.message}`, { + path: request.routeOptions.url, params: request.params, query: request.query, code: error.name, diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 7b928263a..a9b9a55bc 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -57,6 +57,9 @@ export const moderationLogTypes = [ 'unmarkSensitiveDriveFile', 'resolveAbuseReport', 'createInvitation', + 'createAd', + 'updateAd', + 'deleteAd', ] as const; export type ModerationLogPayloads = { @@ -202,4 +205,28 @@ export type ModerationLogPayloads = { createInvitation: { invitations: any[]; }; + createAd: { + adId: string; + ad: any; + }; + updateAd: { + adId: string; + before: any; + after: any; + }; + deleteAd: { + adId: string; + ad: any; + }; +}; + +export type Serialized = { + [K in keyof T]: + T[K] extends Date + ? string + : T[K] extends (Date | null) + ? (string | null) + : T[K] extends Record + ? Serialized + : T[K]; }; diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index c9e1ccc30..7d57ba17b 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -6,7 +6,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import { signup, api, post, react, startServer, waitFire, sleep } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; import type * as misskey from 'misskey-js'; @@ -42,6 +42,9 @@ describe('Renote Mute', () => { const carolRenote = await post(carol, { renoteId: bobNote.id }); const carolNote = await post(carol, { text: 'hi' }); + // redisに追加されるのを待つ + await sleep(100); + const res = await api('/notes/local-timeline', {}, alice); assert.strictEqual(res.status, 200); @@ -56,6 +59,9 @@ describe('Renote Mute', () => { const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); const carolNote = await post(carol, { text: 'hi' }); + // redisに追加されるのを待つ + await sleep(100); + const res = await api('/notes/local-timeline', {}, alice); assert.strictEqual(res.status, 200); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts new file mode 100644 index 000000000..f4c7ffc82 --- /dev/null +++ b/packages/backend/test/e2e/timelines.ts @@ -0,0 +1,701 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; +process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true'; + +import * as assert from 'assert'; +import { signup, api, post, react, startServer, waitFire, sleep, uploadUrl } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; + +let app: INestApplicationContext; + +beforeAll(async () => { + app = await startServer(); +}, 1000 * 60 * 2); + +afterAll(async () => { + await app.close(); +}); + +describe('Timelines', () => { + describe('Home TL', () => { + test.concurrent('自分の visibility: followers なノートが含まれる', async () => { + const [alice] = await Promise.all([signup()]); + + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + }); + + test.concurrent('フォローしているユーザーのノートが含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); + const carolNote = await post(carol, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await api('/following/update', { userId: bob.id, withReplies: true }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await api('/following/update', { userId: bob.id, withReplies: true }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await api('/following/update', { userId: bob.id, withReplies: true }, alice); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await api('/following/create', { userId: carol.id }, alice); + await api('/following/update', { userId: bob.id, withReplies: true }, alice); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); + }); + + test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await api('/following/create', { userId: carol.id }, alice); + await api('/following/update', { userId: bob.id, withReplies: true }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + }); + + test.concurrent('自分の他人への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi' }); + const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + + test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { renoteId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { renoteId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', { + withRenotes: false, + }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', { + withRenotes: false, + }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await api('/mute/create', { userId: carol.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await api('/following/update', { userId: bob.id, withReplies: true }, alice); + await api('/mute/create', { userId: carol.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); + + await api('/following/create', { userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); + + await api('/following/create', { userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const [bobFile, carolFile] = await Promise.all([ + uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'), + uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'), + ]); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { fileIds: [bobFile.id] }); + const carolNote1 = await post(carol, { text: 'hi' }); + const carolNote2 = await post(carol, { fileIds: [carolFile.id] }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/timeline', { withFiles: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false); + }, 1000 * 10); + }); + + describe('Local TL', () => { + test.concurrent('visibility: home なノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); + const bobNote = await post(bob, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('リモートユーザーのノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); + + const bobNote = await post(bob, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + // 含まれても良いと思うけど実装が面倒なので含まれない + test.concurrent('フォローしているユーザーの visibility: home なノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { + userId: carol.id, + }, alice); + const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); + const bobNote = await post(bob, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('ミュートしているユーザーのノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/mute/create', { userId: carol.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await api('/mute/create', { userId: carol.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await api('/following/update', { userId: bob.id, withReplies: true }, alice); + await api('/mute/create', { userId: carol.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { fileIds: [file.id] }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/local-timeline', { withFiles: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + }, 1000 * 10); + }); + + describe('Social TL', () => { + test.concurrent('ローカルユーザーのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/hybrid-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/hybrid-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/hybrid-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test.concurrent('リモートユーザーのノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); + + const bobNote = await post(bob, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); + + await api('/following/create', { userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/hybrid-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: 'example.com' })]); + + await api('/following/create', { userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/hybrid-timeline', {}, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { fileIds: [file.id] }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/hybrid-timeline', { withFiles: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + }, 1000 * 10); + }); + + describe('User List TL', () => { + test.concurrent('リスインしているフォローしていないユーザーのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + /* 未実装 + test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + */ + + test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれるが隠される', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, null); + }); + + test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + }); + + test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + await api('/users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + }); + + test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/icon.png'); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { fileIds: [file.id] }); + + await sleep(100); // redisに追加されるのを待つ + + const res = await api('/notes/user-list-timeline', { listId: list.id, withFiles: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + }, 1000 * 10); + }); + + // TODO: リノートミュート済みユーザーのテスト + // TODO: ページネーションのテスト +}); diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index 121070787..b5f00a632 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -38,23 +38,10 @@ describe('users/notes', () => { await app.close(); }); - test('ファイルタイプ指定 (jpg)', async () => { + test('withFiles', async () => { const res = await api('/users/notes', { userId: alice.id, - fileType: ['image/jpeg'], - }, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.length, 2); - assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true); - }); - - test('ファイルタイプ指定 (jpg or png)', async () => { - const res = await api('/users/notes', { - userId: alice.id, - fileType: ['image/jpeg', 'image/png'], + withFiles: true, }, alice); assert.strictEqual(res.status, 200); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 0b3f20026..53db1ac28 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -133,6 +133,7 @@ describe('ユーザー', () => { isMuted: user.isMuted ?? false, isRenoteMuted: user.isRenoteMuted ?? false, notify: user.notify ?? 'none', + withReplies: user.withReplies ?? false, }); }; @@ -167,6 +168,7 @@ describe('ユーザー', () => { mutedWords: user.mutedWords, mutedInstances: user.mutedInstances, mutingNotificationTypes: user.mutingNotificationTypes, + notificationRecieveConfig: user.notificationRecieveConfig, emailNotificationTypes: user.emailNotificationTypes, achievements: user.achievements, loggedInDays: user.loggedInDays, @@ -415,6 +417,7 @@ describe('ユーザー', () => { assert.deepStrictEqual(response.mutedWords, []); assert.deepStrictEqual(response.mutedInstances, []); assert.deepStrictEqual(response.mutingNotificationTypes, []); + assert.deepStrictEqual(response.notificationRecieveConfig, {}); assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.loggedInDays, 0); @@ -495,8 +498,8 @@ describe('ユーザー', () => { { parameters: (): object => ({ mutedWords: [] }) }, { parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) }, { parameters: (): object => ({ mutedInstances: [] }) }, - { parameters: (): object => ({ mutingNotificationTypes: ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) }, - { parameters: (): object => ({ mutingNotificationTypes: [] }) }, + { parameters: (): object => ({ notificationRecieveConfig: { mention: { type: 'following' } } }) }, + { parameters: (): object => ({ notificationRecieveConfig: {} }) }, { parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) }, { parameters: (): object => ({ emailNotificationTypes: [] }) }, ] as const)('を書き換えることができる($#)', async ({ parameters }) => { diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index adc532bbe..fae901842 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -99,9 +99,17 @@ export const relativeFetch = async (path: string, init?: RequestInit | undefined return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init); }; +function randomString(chars = 'abcdefghijklmnopqrstuvwxyz0123456789', length = 16) { + let randomString = ''; + for (let i = 0; i < length; i++) { + randomString += chars[Math.floor(Math.random() * chars.length)]; + } + return randomString; +} + export const signup = async (params?: Partial): Promise> => { const q = Object.assign({ - username: 'test', + username: randomString(), password: 'test', }, params); diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html index ed38e4964..36ff34b48 100644 --- a/packages/frontend/.storybook/preview-head.html +++ b/packages/frontend/.storybook/preview-head.html @@ -1,6 +1,6 @@ - + diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index fbd938f74..595438b80 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -279,6 +279,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+ @@ -51,7 +51,7 @@ let disabled = $ref(false); const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); const withRenotes = $ref(props.column.withRenotes ?? true); -const withReplies = $ref(props.column.withReplies ?? false); +const onlyFiles = $ref(props.column.onlyFiles ?? false); watch($$(withRenotes), v => { updateColumn(props.column.id, { @@ -59,9 +59,9 @@ watch($$(withRenotes), v => { }); }); -watch($$(withReplies), v => { +watch($$(onlyFiles), v => { updateColumn(props.column.id, { - withReplies: v, + onlyFiles: v, }); }); @@ -109,8 +109,8 @@ const menu = [{ ref: $$(withRenotes), }, { type: 'switch', - text: i18n.ts.withReplies, - ref: $$(withReplies), + text: i18n.ts.fileAttachedOnly, + ref: $$(onlyFiles), }]; diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue index e4fb5f291..1590f1799 100644 --- a/packages/frontend/src/widgets/WidgetActivity.vue +++ b/packages/frontend/src/widgets/WidgetActivity.vue @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only