Merge branch 'misskey-dev:develop' into develop

This commit is contained in:
老兄 2023-10-10 21:51:40 +08:00 committed by GitHub
commit 4d04ebad04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
109 changed files with 1959 additions and 1035 deletions

View File

@ -15,8 +15,7 @@
## 2023.10.0 ## 2023.10.0
### NOTE ### NOTE
- 2023.9.2で導入されたノート編集機能はクオリティの高い実装が困難であることが判明したため撤回されました - 2023.9.2で導入されたノート編集機能はクオリティの高い実装が困難であることが判明したため撤回されました
- アップデート後、アップデートより前の時点にTLを遡ることはできません - アップデートを行うと、タイムラインが一時的にリセットされます
- アップデート後であっても、今後のアップデートで2023.10.0以前のTLに遡れるようになる可能性はあります
### Changes ### Changes
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました - API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
@ -28,6 +27,9 @@
- Feat: ユーザーごとのハイライト - Feat: ユーザーごとのハイライト
- Feat: プライバシーポリシー・運営者情報Impressumの指定が可能になりました - Feat: プライバシーポリシー・運営者情報Impressumの指定が可能になりました
- プライバシーポリシーはサーバー登録時に同意確認が入ります - プライバシーポリシーはサーバー登録時に同意確認が入ります
- Feat: タイムラインがリアルタイム更新中に広告を挿入できるようになりました
- デフォルトは無効
- 頻度はコントロールパネルから設定できます。運営中のサーバーのTLの流速を見て、最適な値を指定してください。
- Enhance: ソフトワードミュートとハードワードミュートは統合されました - Enhance: ソフトワードミュートとハードワードミュートは統合されました
- Enhance: モデレーションログ機能の強化 - Enhance: モデレーションログ機能の強化
- Enhance: ローカリゼーションの更新 - Enhance: ローカリゼーションの更新
@ -36,15 +38,26 @@
- Fix: ユーザーリストTLにチャンネル投稿が含まれる問題を修正 - Fix: ユーザーリストTLにチャンネル投稿が含まれる問題を修正
### Client ### Client
- Feat: 「ファイルの詳細」ページを追加
- ドライブのファイルの拡大プレビューができるように
- ファイルが添付されたノートの一覧が表示できるように
- Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に - Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に
- Enhance: 動画再生時のデフォルトボリュームを30%に
- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正 - Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正
### Server ### Server
- Enhance: drive/files/attached-notes がページネーションに対応しました
- Enhance: タイムライン取得時のパフォーマンスを大幅に向上 - Enhance: タイムライン取得時のパフォーマンスを大幅に向上
- Enhance: ハイライト取得時のパフォーマンスを大幅に向上 - Enhance: ハイライト取得時のパフォーマンスを大幅に向上
- Enhance: トレンドハッシュタグ取得時のパフォーマンスを大幅に向上 - Enhance: トレンドハッシュタグ取得時のパフォーマンスを大幅に向上
- Enhance: WebSocket接続が多い場合のパフォーマンスを向上
- Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上 - Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上
- Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正 - Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正
- Fix: nodeinfoにおいてCORS用のヘッダーが設定されていないのを修正
- Fix: 同じ種類のTLのストリーミングを複数接続できない問題を修正
- Fix: アンテナTLを途中までしかページネーションできなくなることがある問題を修正
- Fix: 「ファイル付きのみ」のTLでファイル無しの新着ートが流れる問題を修正
- Fix: プロセスが終了しない、あるいは非常に時間がかかる問題を修正
## 2023.9.3 ## 2023.9.3
### General ### General

View File

@ -995,9 +995,6 @@ _theme:
infoFg: "তথ্যের পাঠ্য" infoFg: "তথ্যের পাঠ্য"
infoWarnBg: "ওয়ার্নিং এর পটভূমি" infoWarnBg: "ওয়ার্নিং এর পটভূমি"
infoWarnFg: "ওয়ার্নিং এর পাঠ্য" infoWarnFg: "ওয়ার্নিং এর পাঠ্য"
cwBg: "CW বাটনের পটভূমি"
cwFg: "CW বাটনের পাঠ্য"
cwHoverBg: "CW বাটনের পটভূমি (হভার)"
toastBg: "বিজ্ঞপ্তির পটভূমি" toastBg: "বিজ্ঞপ্তির পটভূমি"
toastFg: "বিজ্ঞপ্তির পাঠ্য" toastFg: "বিজ্ঞপ্তির পাঠ্য"
buttonBg: "বাটনের পটভূমি" buttonBg: "বাটনের পটভূমি"

View File

@ -1622,9 +1622,6 @@ _theme:
infoFg: "Text informací" infoFg: "Text informací"
infoWarnBg: "Pozadí varování" infoWarnBg: "Pozadí varování"
infoWarnFg: "Text varování" infoWarnFg: "Text varování"
cwBg: "Pozadí CW tlačítka"
cwFg: "Text CW tlačítka"
cwHoverBg: "Pozadí CW tlačítka (Hover)"
toastBg: "Pozadí oznámení" toastBg: "Pozadí oznámení"
toastFg: "Text oznámení" toastFg: "Text oznámení"
buttonBg: "Pozadí tlačítka" buttonBg: "Pozadí tlačítka"

View File

@ -1129,6 +1129,12 @@ fileAttachedOnly: "Nur Notizen mit Dateien"
showRepliesToOthersInTimeline: "Antworten in Chronik anzeigen" showRepliesToOthersInTimeline: "Antworten in Chronik anzeigen"
hideRepliesToOthersInTimeline: "Antworten nicht in Chronik anzeigen" hideRepliesToOthersInTimeline: "Antworten nicht in Chronik anzeigen"
externalServices: "Externe Dienste" externalServices: "Externe Dienste"
impressum: "Impressum"
impressumUrl: "Impressums-URL"
impressumDescription: "In manchen Ländern, wie Deutschland und dessen Umgebung, ist die Angabe von Betreiberinformationen (ein Impressum) bei kommerziellem Betrieb zwingend."
privacyPolicy: "Datenschutzerklärung"
privacyPolicyUrl: "Datenschutzerklärungs-URL"
tosAndPrivacyPolicy: "Nutzungsbedingungen und Datenschutzerklärung"
_announcement: _announcement:
forExistingUsers: "Nur für existierende Nutzer" 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." 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."
@ -1527,6 +1533,10 @@ _ad:
reduceFrequencyOfThisAd: "Diese Werbung weniger anzeigen" reduceFrequencyOfThisAd: "Diese Werbung weniger anzeigen"
hide: "Ausblenden" hide: "Ausblenden"
timezoneinfo: "Der Wochentag wird durch die Serverzeitzone bestimmt." timezoneinfo: "Der Wochentag wird durch die Serverzeitzone bestimmt."
adsSettings: "Werbeeinstellungen"
notesPerOneAd: "Werbeintervall während Echtzeitaktualisierung (Notizen pro Werbung)"
setZeroToDisable: "Setze dies auf 0, um Werbung während Echtzeitaktualisierung zu deaktivieren"
adsTooClose: "Durch den momentan sehr niedrigen Werbeintervall kann es zu einer starken Verschlechterung der Benutzererfahrung kommen."
_forgotPassword: _forgotPassword:
enterEmail: "Gib die Email-Adresse ein, mit der du dich registriert hast. An diese wird ein Link gesendet, mit dem du dein Passwort zurücksetzen kannst." enterEmail: "Gib die Email-Adresse ein, mit der du dich registriert hast. An diese wird ein Link gesendet, mit dem du dein Passwort zurücksetzen kannst."
ifNoEmail: "Solltest du bei der Registrierung keine Email-Adresse angegeben haben, wende dich bitte an den Administrator." ifNoEmail: "Solltest du bei der Registrierung keine Email-Adresse angegeben haben, wende dich bitte an den Administrator."
@ -1675,9 +1685,6 @@ _theme:
infoFg: "Text von Informationen" infoFg: "Text von Informationen"
infoWarnBg: "Hintergrund von Warnungen" infoWarnBg: "Hintergrund von Warnungen"
infoWarnFg: "Text von Warnungen" infoWarnFg: "Text von Warnungen"
cwBg: "Hintergrund des Inhaltswarnungsknopfs"
cwFg: "Text des Inhaltswarnungsknopfs"
cwHoverBg: "Hintergrund des Inhaltswarnungsknopfs (Mouseover)"
toastBg: "Hintergrund von Benachrichtigungen" toastBg: "Hintergrund von Benachrichtigungen"
toastFg: "Text von Benachrichtigungen" toastFg: "Text von Benachrichtigungen"
buttonBg: "Hintergrund von Schaltflächen" buttonBg: "Hintergrund von Schaltflächen"
@ -2134,3 +2141,11 @@ _moderationLogTypes:
createAd: "Werbung erstellt" createAd: "Werbung erstellt"
deleteAd: "Werbung gelöscht" deleteAd: "Werbung gelöscht"
updateAd: "Werbung aktualisiert" updateAd: "Werbung aktualisiert"
_fileViewer:
title: "Dateiinformationen"
type: "Dateityp"
size: "Dateigröße"
url: "URL"
uploadedAt: "Hochgeladen am"
attachedNotes: "Zugehörige Notizen"
thisPageCanBeSeenFromTheAuthor: "Nur der Benutzer, der diese Datei hochgeladen hat, kann diese Seite sehen."

View File

@ -1129,6 +1129,12 @@ fileAttachedOnly: "Only notes with files"
showRepliesToOthersInTimeline: "Show replies to others in TL" showRepliesToOthersInTimeline: "Show replies to others in TL"
hideRepliesToOthersInTimeline: "Hide replies to others from TL" hideRepliesToOthersInTimeline: "Hide replies to others from TL"
externalServices: "External Services" externalServices: "External Services"
impressum: "Impressum"
impressumUrl: "Impressum URL"
impressumDescription: "In some countries, like germany, the inclusion of operator contact information (an Impressum) is legally required for commercial websites."
privacyPolicy: "Privacy Policy"
privacyPolicyUrl: "Privacy Policy URL"
tosAndPrivacyPolicy: "Terms of Service and Privacy Policy"
_announcement: _announcement:
forExistingUsers: "Existing users only" 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." 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."
@ -1527,6 +1533,10 @@ _ad:
reduceFrequencyOfThisAd: "Show this ad less" reduceFrequencyOfThisAd: "Show this ad less"
hide: "Hide" hide: "Hide"
timezoneinfo: "The day of the week is determined from the server's timezone." timezoneinfo: "The day of the week is determined from the server's timezone."
adsSettings: "Ad settings"
notesPerOneAd: "Real-time update ad placement interval (Notes per ad)"
setZeroToDisable: "Set this value to 0 to disable real-time update ads"
adsTooClose: "The current ad interval may significantly worsen the user experience due to being too low."
_forgotPassword: _forgotPassword:
enterEmail: "Enter the email address you used to register. A link with which you can reset your password will then be sent to it." enterEmail: "Enter the email address you used to register. A link with which you can reset your password will then be sent to it."
ifNoEmail: "If you did not use an email during registration, please contact the instance administrator instead." ifNoEmail: "If you did not use an email during registration, please contact the instance administrator instead."
@ -1675,9 +1685,6 @@ _theme:
infoFg: "Information text" infoFg: "Information text"
infoWarnBg: "Warning background" infoWarnBg: "Warning background"
infoWarnFg: "Warning text" infoWarnFg: "Warning text"
cwBg: "CW button background"
cwFg: "CW button text"
cwHoverBg: "CW button background (Hover)"
toastBg: "Notification background" toastBg: "Notification background"
toastFg: "Notification text" toastFg: "Notification text"
buttonBg: "Button background" buttonBg: "Button background"
@ -2134,3 +2141,11 @@ _moderationLogTypes:
createAd: "Ad created" createAd: "Ad created"
deleteAd: "Ad deleted" deleteAd: "Ad deleted"
updateAd: "Ad updated" updateAd: "Ad updated"
_fileViewer:
title: "File details"
type: "File type"
size: "Filesize"
url: "URL"
uploadedAt: "Uploaded at"
attachedNotes: "Attached notes"
thisPageCanBeSeenFromTheAuthor: "This page can only be seen by the user who uploaded this file."

View File

@ -1666,9 +1666,6 @@ _theme:
infoFg: "Texto de información" infoFg: "Texto de información"
infoWarnBg: "Fondo de advertencias" infoWarnBg: "Fondo de advertencias"
infoWarnFg: "Texto de advertencias" infoWarnFg: "Texto de advertencias"
cwBg: "Fondo del botón CW"
cwFg: "Texto del botón CW"
cwHoverBg: "Fondo del botón CW (hover)"
toastBg: "Fondo de notificaciones" toastBg: "Fondo de notificaciones"
toastFg: "Texto de notificaciones" toastFg: "Texto de notificaciones"
buttonBg: "Fondo de botón" buttonBg: "Fondo de botón"

View File

@ -45,6 +45,7 @@ pin: "Épingler sur le profil"
unpin: "Désépingler" unpin: "Désépingler"
copyContent: "Copier le contenu" copyContent: "Copier le contenu"
copyLink: "Copier le lien" copyLink: "Copier le lien"
copyLinkRenote: "Copier le lien de la renote"
delete: "Supprimer" delete: "Supprimer"
deleteAndEdit: "Supprimer et réécrire" deleteAndEdit: "Supprimer et réécrire"
deleteAndEditConfirm: "Êtes-vous sûr de vouloir effacer cette note et la modifier ? Vous perdrez toutes les réactions, renotes et réponses." deleteAndEditConfirm: "Êtes-vous sûr de vouloir effacer cette note et la modifier ? Vous perdrez toutes les réactions, renotes et réponses."
@ -129,6 +130,8 @@ unmarkAsSensitive: "Supprimer le marquage comme sensible"
enterFileName: "Entrer le nom du fichier" enterFileName: "Entrer le nom du fichier"
mute: "Masquer" mute: "Masquer"
unmute: "Ne plus masquer" unmute: "Ne plus masquer"
renoteMute: "Masquer les renotes"
renoteUnmute: "Ne plus masquer les renotes"
block: "Bloquer" block: "Bloquer"
unblock: "Débloquer" unblock: "Débloquer"
suspend: "Suspendre" suspend: "Suspendre"
@ -414,6 +417,7 @@ moderator: "Modérateur·rice·s"
moderation: "Modérations" moderation: "Modérations"
moderationNote: "Note de modération" moderationNote: "Note de modération"
addModerationNote: "Ajouter une note de modération" addModerationNote: "Ajouter une note de modération"
moderationLogs: "Journal de modération"
nUsersMentioned: "{n} utilisateur·rice·s mentionné·e·s" nUsersMentioned: "{n} utilisateur·rice·s mentionné·e·s"
securityKeyAndPasskey: "Sécurité et clés de sécurité" securityKeyAndPasskey: "Sécurité et clés de sécurité"
securityKey: "Clé de sécurité" securityKey: "Clé de sécurité"
@ -472,6 +476,7 @@ aboutX: "À propos de {x}"
emojiStyle: "Style des émojis" emojiStyle: "Style des émojis"
native: "Natif" native: "Natif"
disableDrawer: "Les menus ne s'affichent pas dans le tiroir" disableDrawer: "Les menus ne s'affichent pas dans le tiroir"
showNoteActionsOnlyHover: "Afficher les actions de note uniquement au survol"
noHistory: "Pas d'historique" noHistory: "Pas d'historique"
signinHistory: "Historique de connexion" signinHistory: "Historique de connexion"
enableAdvancedMfm: "Activer la MFM avancée" enableAdvancedMfm: "Activer la MFM avancée"
@ -647,6 +652,7 @@ behavior: "Comportement"
sample: "Exemple" sample: "Exemple"
abuseReports: "Signalements" abuseReports: "Signalements"
reportAbuse: "Signaler" reportAbuse: "Signaler"
reportAbuseRenote: "Signaler la renote"
reportAbuseOf: "Signaler {name}" reportAbuseOf: "Signaler {name}"
fillAbuseReportDescription: "Veuillez expliquer les raisons du signalement. S'il s'agit d'une note précise, veuillez en donner le lien." fillAbuseReportDescription: "Veuillez expliquer les raisons du signalement. S'il s'agit d'une note précise, veuillez en donner le lien."
abuseReported: "Le rapport est envoyé. Merci." abuseReported: "Le rapport est envoyé. Merci."
@ -671,6 +677,8 @@ clip: "Clip"
createNew: "Créer nouveau" createNew: "Créer nouveau"
optional: "Facultatif" optional: "Facultatif"
createNewClip: "Créer un nouveau clip" createNewClip: "Créer un nouveau clip"
unclip: "Supprimer le clip"
confirmToUnclipAlreadyClippedNote: "Cette note fait déjà partie du clip « {name} ». Souhaitez-vous la supprimer de ce clip ?"
public: "Public" public: "Public"
private: "Privé" private: "Privé"
i18nInfo: "Misskey est traduit dans différentes langues par des bénévoles. Vous pouvez contribuer à {link}." i18nInfo: "Misskey est traduit dans différentes langues par des bénévoles. Vous pouvez contribuer à {link}."
@ -933,12 +941,15 @@ unsubscribePushNotification: "Désactiver les notifications push"
pushNotificationAlreadySubscribed: "Les notifications push sont déjà activées" pushNotificationAlreadySubscribed: "Les notifications push sont déjà activées"
pushNotificationNotSupported: "Votre navigateur ou votre instance ne prend pas en charge les notifications push" pushNotificationNotSupported: "Votre navigateur ou votre instance ne prend pas en charge les notifications push"
sendPushNotificationReadMessage: "Supprimer les notifications push une fois que les notifications ou messages pertinents ont été lus." sendPushNotificationReadMessage: "Supprimer les notifications push une fois que les notifications ou messages pertinents ont été lus."
windowMaximize: "Maximiser"
windowMinimize: "Minimaliser"
windowRestore: "Restaurer" windowRestore: "Restaurer"
caption: "Libellé" caption: "Libellé"
loggedInAsBot: "Connecté actuellement en tant que bot" loggedInAsBot: "Connecté actuellement en tant que bot"
tools: "Outils" tools: "Outils"
cannotLoad: "Chargement impossible" cannotLoad: "Chargement impossible"
like: "J'aime" like: "J'aime"
unlike: "Ne plus aimer"
numberOfLikes: "Favoris" numberOfLikes: "Favoris"
show: "Affichage" show: "Affichage"
neverShow: "Ne plus afficher" neverShow: "Ne plus afficher"
@ -949,6 +960,7 @@ noRole: "Aucun rôle"
normalUser: "Simple utilisateur·rice" normalUser: "Simple utilisateur·rice"
undefined: "Non défini" undefined: "Non défini"
assign: "Attribuer" assign: "Attribuer"
unassign: "Retirer"
color: "Couleur" color: "Couleur"
manageCustomEmojis: "Gestion des émojis personnalisés" manageCustomEmojis: "Gestion des émojis personnalisés"
preset: "Préréglage" preset: "Préréglage"
@ -958,12 +970,16 @@ thisPostMayBeAnnoying: "Cette note peut gêner d'autres personnes."
thisPostMayBeAnnoyingHome: "Publier vers le fil principal" thisPostMayBeAnnoyingHome: "Publier vers le fil principal"
thisPostMayBeAnnoyingCancel: "Annuler" thisPostMayBeAnnoyingCancel: "Annuler"
thisPostMayBeAnnoyingIgnore: "Publier quand-même" thisPostMayBeAnnoyingIgnore: "Publier quand-même"
collapseRenotes: "Réduire les renotes déjà vues"
internalServerError: "Erreur interne du serveur" internalServerError: "Erreur interne du serveur"
copyErrorInfo: "Copier les détails de lerreur" copyErrorInfo: "Copier les détails de lerreur"
exploreOtherServers: "Trouver une autre instance" exploreOtherServers: "Trouver une autre instance"
disableFederationOk: "Désactiver" disableFederationOk: "Désactiver"
likeOnly: "Les favoris uniquement" likeOnly: "Les favoris uniquement"
sensitiveWords: "Mots sensibles"
notesSearchNotAvailable: "La recherche de notes n'est pas disponible."
license: "Licence" license: "Licence"
myClips: "Mes clips"
video: "Vidéo" video: "Vidéo"
videos: "Vidéos" videos: "Vidéos"
dataSaver: "Économiseur de données" dataSaver: "Économiseur de données"
@ -973,6 +989,7 @@ accountMovedShort: "Ce compte a migré"
operationForbidden: "Opération non autorisée" operationForbidden: "Opération non autorisée"
addMemo: "Ajouter un mémo" addMemo: "Ajouter un mémo"
reactionsList: "Réactions" reactionsList: "Réactions"
renotesList: "Liste de renotes"
notificationDisplay: "Style des notifications" notificationDisplay: "Style des notifications"
leftTop: "En haut à gauche" leftTop: "En haut à gauche"
rightTop: "En haut à droite" rightTop: "En haut à droite"
@ -982,6 +999,7 @@ vertical: "Vertical"
horizontal: "Latéral" horizontal: "Latéral"
serverRules: "Règles du serveur" serverRules: "Règles du serveur"
archive: "Archive" archive: "Archive"
displayOfNote: "Affichage de la note"
youFollowing: "Abonné·e" youFollowing: "Abonné·e"
options: "Options" options: "Options"
later: "Plus tard" later: "Plus tard"
@ -1001,6 +1019,7 @@ pinnedList: "Liste épinglée"
notifyNotes: "Notifier à propos des nouvelles notes" notifyNotes: "Notifier à propos des nouvelles notes"
authentication: "Authentification" authentication: "Authentification"
authenticationRequiredToContinue: "Veuillez vous authentifier pour continuer" authenticationRequiredToContinue: "Veuillez vous authentifier pour continuer"
showRenotes: "Afficher les renotes"
_announcement: _announcement:
readConfirmTitle: "Marquer comme lu ?" readConfirmTitle: "Marquer comme lu ?"
_initialAccountSetting: _initialAccountSetting:
@ -1082,12 +1101,20 @@ _achievements:
title: "Beaucoup d'amis" title: "Beaucoup d'amis"
_followers10: _followers10:
title: "Abonnez-moi !" title: "Abonnez-moi !"
description: "Obtenir plus de 10 abonné·e·s"
_followers50:
description: "Obtenir plus de 50 abonné·e·s"
_followers100: _followers100:
title: "Populaire" title: "Populaire"
description: "Obtenir plus de 100 abonné·e·s"
_followers300:
description: "Obtenir plus de 300 abonné·e·s"
_followers500: _followers500:
title: "Tour radio" title: "Tour radio"
description: "Obtenir plus de 500 abonné·e·s"
_followers1000: _followers1000:
title: "Influenceur·euse" title: "Influenceur·euse"
description: "Obtenir plus de 1000 abonné·e·s"
_iLoveMisskey: _iLoveMisskey:
title: "Jadore Misskey" title: "Jadore Misskey"
description: "Publication « J❤ #Misskey »" description: "Publication « J❤ #Misskey »"
@ -1151,6 +1178,7 @@ _role:
high: "Haute" high: "Haute"
_options: _options:
canManageCustomEmojis: "Gestion des émojis personnalisés" canManageCustomEmojis: "Gestion des émojis personnalisés"
wordMuteMax: "Nombre maximal de caractères dans le filtre de mots"
_sensitiveMediaDetection: _sensitiveMediaDetection:
description: "L'apprentissage automatique peut être utilisé pour détecter automatiquement les médias sensibles à modérer. La sollicitation des serveurs augmente légèrement." description: "L'apprentissage automatique peut être utilisé pour détecter automatiquement les médias sensibles à modérer. La sollicitation des serveurs augmente légèrement."
sensitivity: "Sensibilité de la détection" sensitivity: "Sensibilité de la détection"
@ -1330,9 +1358,6 @@ _theme:
infoFg: "Texte d'information" infoFg: "Texte d'information"
infoWarnBg: "Arrière-plan des avertissements" infoWarnBg: "Arrière-plan des avertissements"
infoWarnFg: "Texte davertissement" infoWarnFg: "Texte davertissement"
cwBg: "Arrière-plan du CW"
cwFg: "Texte du bouton CW"
cwHoverBg: "Arrière-plan du bouton CW (survolé)"
toastBg: "Arrière-plan de la bulle de notification" toastBg: "Arrière-plan de la bulle de notification"
toastFg: "Texte de la bulle de notification" toastFg: "Texte de la bulle de notification"
buttonBg: "Arrière-plan du bouton" buttonBg: "Arrière-plan du bouton"

View File

@ -1627,9 +1627,6 @@ _theme:
infoFg: "Teks informasi" infoFg: "Teks informasi"
infoWarnBg: "Latar belakang peringatan" infoWarnBg: "Latar belakang peringatan"
infoWarnFg: "Teks peringatan" infoWarnFg: "Teks peringatan"
cwBg: "Latar belakang tombol Sembunyikan Konten"
cwFg: "Teks tombol Sembunyikan Konten"
cwHoverBg: "Latar belakang tombol Sembunyikan Konten (Mengambang)"
toastBg: "Latar belakang notifikasi" toastBg: "Latar belakang notifikasi"
toastFg: "Teks notifikasi" toastFg: "Teks notifikasi"
buttonBg: "Latar belakang tombol" buttonBg: "Latar belakang tombol"

16
locales/index.d.ts vendored
View File

@ -1627,6 +1627,10 @@ export interface Locale {
"reduceFrequencyOfThisAd": string; "reduceFrequencyOfThisAd": string;
"hide": string; "hide": string;
"timezoneinfo": string; "timezoneinfo": string;
"adsSettings": string;
"notesPerOneAd": string;
"setZeroToDisable": string;
"adsTooClose": string;
}; };
"_forgotPassword": { "_forgotPassword": {
"enterEmail": string; "enterEmail": string;
@ -1792,9 +1796,6 @@ export interface Locale {
"infoFg": string; "infoFg": string;
"infoWarnBg": string; "infoWarnBg": string;
"infoWarnFg": string; "infoWarnFg": string;
"cwBg": string;
"cwFg": string;
"cwHoverBg": string;
"toastBg": string; "toastBg": string;
"toastFg": string; "toastFg": string;
"buttonBg": string; "buttonBg": string;
@ -2290,6 +2291,15 @@ export interface Locale {
"deleteAd": string; "deleteAd": string;
"updateAd": string; "updateAd": string;
}; };
"_fileViewer": {
"title": string;
"type": string;
"size": string;
"url": string;
"uploadedAt": string;
"attachedNotes": string;
"thisPageCanBeSeenFromTheAuthor": string;
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View File

@ -64,7 +64,7 @@ reply: "Rispondi"
loadMore: "Mostra di più" loadMore: "Mostra di più"
showMore: "Espandi" showMore: "Espandi"
showLess: "Comprimi" showLess: "Comprimi"
youGotNewFollower: "Ti sta seguendo" youGotNewFollower: "Adesso ti segue"
receiveFollowRequest: "Hai ricevuto una richiesta di follow" receiveFollowRequest: "Hai ricevuto una richiesta di follow"
followRequestAccepted: "Ha accettato la tua richiesta di follow" followRequestAccepted: "Ha accettato la tua richiesta di follow"
mention: "Menzioni" mention: "Menzioni"
@ -113,7 +113,7 @@ cantReRenote: "È impossibile rinotare una Rinota."
quote: "Cita" quote: "Cita"
inChannelRenote: "Rinota nel canale" inChannelRenote: "Rinota nel canale"
inChannelQuote: "Cita nel canale" inChannelQuote: "Cita nel canale"
pinnedNote: "Nota fissata" pinnedNote: "Nota in primo piano"
pinned: "Fissa sul profilo" pinned: "Fissa sul profilo"
you: "Tu" you: "Tu"
clickToShow: "Clicca per visualizzare" clickToShow: "Clicca per visualizzare"
@ -336,7 +336,7 @@ instanceName: "Nome dell'istanza"
instanceDescription: "Descrizione dell'istanza" instanceDescription: "Descrizione dell'istanza"
maintainerName: "Nome dell'amministratore" maintainerName: "Nome dell'amministratore"
maintainerEmail: "Indirizzo e-mail dell'amministratore" maintainerEmail: "Indirizzo e-mail dell'amministratore"
tosUrl: "URL dei termini del servizio e della privacy" tosUrl: "URL delle condizioni d'uso"
thisYear: "Anno" thisYear: "Anno"
thisMonth: "Mese" thisMonth: "Mese"
today: "Oggi" today: "Oggi"
@ -364,7 +364,7 @@ pinnedUsersDescription: "Elenca gli/le utenti che vuoi fissare in cima alla pagi
pinnedPages: "Pagine in evidenza" pinnedPages: "Pagine in evidenza"
pinnedPagesDescription: "Specifica il percorso delle pagine che vuoi fissare in cima alla pagina dell'istanza. Una pagina per riga." pinnedPagesDescription: "Specifica il percorso delle pagine che vuoi fissare in cima alla pagina dell'istanza. Una pagina per riga."
pinnedClipId: "ID della Clip in evidenza" pinnedClipId: "ID della Clip in evidenza"
pinnedNotes: "Nota fissata" pinnedNotes: "Note in primo piano"
hcaptcha: "hCaptcha" hcaptcha: "hCaptcha"
enableHcaptcha: "Abilita hCaptcha" enableHcaptcha: "Abilita hCaptcha"
hcaptchaSiteKey: "Chiave del sito" hcaptchaSiteKey: "Chiave del sito"
@ -384,7 +384,7 @@ name: "Nome"
antennaSource: "Fonte dell'antenna" antennaSource: "Fonte dell'antenna"
antennaKeywords: "Parole chiavi da ricevere" antennaKeywords: "Parole chiavi da ricevere"
antennaExcludeKeywords: "Parole chiavi da escludere" antennaExcludeKeywords: "Parole chiavi da escludere"
antennaKeywordsDescription: "Separare con uno spazio indica la condizione \"E\". Separare con un'interruzzione riga indica la condizione \"O\"." antennaKeywordsDescription: "Sparando con uno spazio indichi la condizione E (and). Separando con un a capo, indichi la condizione O (or)."
notifyAntenna: "Invia notifiche delle nuove note" notifyAntenna: "Invia notifiche delle nuove note"
withFileAntenna: "Solo note con file in allegato" withFileAntenna: "Solo note con file in allegato"
enableServiceworker: "Abilita ServiceWorker" enableServiceworker: "Abilita ServiceWorker"
@ -393,7 +393,7 @@ caseSensitive: "Sensibile alla distinzione tra maiuscole e minuscole"
withReplies: "Includere le risposte" withReplies: "Includere le risposte"
connectedTo: "Connessione ai seguenti profili:" connectedTo: "Connessione ai seguenti profili:"
notesAndReplies: "Note e risposte" notesAndReplies: "Note e risposte"
withFiles: "Con file in allegato" withFiles: "Con allegati"
silence: "Silenzia" silence: "Silenzia"
silenceConfirm: "Vuoi davvero silenziare questo profilo?" silenceConfirm: "Vuoi davvero silenziare questo profilo?"
unsilence: "Riattiva" unsilence: "Riattiva"
@ -1121,14 +1121,20 @@ unnotifyNotes: "Interrompi le notifiche di nuove Note"
authentication: "Autenticazione" authentication: "Autenticazione"
authenticationRequiredToContinue: "Per procedere, è richiesta l'autenticazione" authenticationRequiredToContinue: "Per procedere, è richiesta l'autenticazione"
dateAndTime: "Data e Ora" dateAndTime: "Data e Ora"
showRenotes: "Leggi le Rinota" showRenotes: "Includi le Rinota"
edited: "Modificato" edited: "Modificato"
notificationRecieveConfig: "Preferenze di notifica" notificationRecieveConfig: "Preferenze di notifica"
mutualFollow: "Follow reciproco" mutualFollow: "Follow reciproco"
fileAttachedOnly: "Con file in allegato" fileAttachedOnly: "Solo con allegati"
showRepliesToOthersInTimeline: "Risposte altrui nella TL" showRepliesToOthersInTimeline: "Risposte altrui nella TL"
hideRepliesToOthersInTimeline: "Nascondi Riposte altrui nella TL" hideRepliesToOthersInTimeline: "Nascondi Riposte altrui nella TL"
externalServices: "Servizi esterni" externalServices: "Servizi esterni"
impressum: "Dichiarazione di proprietà"
impressumUrl: "URL della dichiarazione di proprietà"
impressumDescription: "La dichiarazione di proprietà, è obbligatoria in alcuni paesi come la Germania (Impressum)."
privacyPolicy: "Informativa sulla privacy"
privacyPolicyUrl: "URL della informativa privacy"
tosAndPrivacyPolicy: "Condizioni d'uso e informativa sulla privacy"
_announcement: _announcement:
forExistingUsers: "Solo ai profili attuali" 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." 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."
@ -1527,6 +1533,10 @@ _ad:
reduceFrequencyOfThisAd: "Visualizza questa pubblicità meno spesso" reduceFrequencyOfThisAd: "Visualizza questa pubblicità meno spesso"
hide: "Nascondi" hide: "Nascondi"
timezoneinfo: "Il giorno della settimana è determinato in base al fuso orario del server." timezoneinfo: "Il giorno della settimana è determinato in base al fuso orario del server."
adsSettings: "Impostazioni banner"
notesPerOneAd: "Quantità di Note tra i banner"
setZeroToDisable: "Imposta 0 (zero) per disattivare la distribuzione dei banner durante gli aggiornamenti in tempo reale"
adsTooClose: "Attenzione, l'intervallo di pubblicazione dei banner è molto breve, potrebbe infastidire significativamente la fruizione"
_forgotPassword: _forgotPassword:
enterEmail: "Inserisci l'indirizzo di posta elettronica che hai registrato nel tuo profilo. Il collegamento necessario per ripristinare la password verrà inviato a questo indirizzo." enterEmail: "Inserisci l'indirizzo di posta elettronica che hai registrato nel tuo profilo. Il collegamento necessario per ripristinare la password verrà inviato a questo indirizzo."
ifNoEmail: "Se il tuo indirizzo email non risulta registrato, contatta l'amministrazione dell'istanza." ifNoEmail: "Se il tuo indirizzo email non risulta registrato, contatta l'amministrazione dell'istanza."
@ -1538,7 +1548,7 @@ _gallery:
unlike: "Non mi piace più" unlike: "Non mi piace più"
_email: _email:
_follow: _follow:
title: "Ha iniziato a seguirti" title: "Adesso ti segue"
_receiveFollowRequest: _receiveFollowRequest:
title: "Hai ricevuto una richiesta di follow" title: "Hai ricevuto una richiesta di follow"
_plugin: _plugin:
@ -1610,7 +1620,7 @@ _menuDisplay:
hide: "Nascondere" hide: "Nascondere"
_wordMute: _wordMute:
muteWords: "Parole da filtrare" muteWords: "Parole da filtrare"
muteWordsDescription: "Separare con uno spazio indica la condizione \"E\". Separare con una interruzione di riga, indica la condizione \"O\"" muteWordsDescription: "Sparando con uno spazio indichi la condizione E (and). Separando con un a capo, indichi la condizione O (or)."
muteWordsDescription2: "Se vuoi indicare delle Espressioni Regolari (regexp), metti la condizione all'interno di due slash (/)" muteWordsDescription2: "Se vuoi indicare delle Espressioni Regolari (regexp), metti la condizione all'interno di due slash (/)"
_instanceMute: _instanceMute:
instanceMuteDescription: "Disattiva tutte le note, le note di rinvio (condivisione) dell'istanza configurata, comprese le risposte agli utenti dell'istanza." instanceMuteDescription: "Disattiva tutte le note, le note di rinvio (condivisione) dell'istanza configurata, comprese le risposte agli utenti dell'istanza."
@ -1620,7 +1630,7 @@ _instanceMute:
_theme: _theme:
explore: "Esplora temi" explore: "Esplora temi"
install: "Installa un tema" install: "Installa un tema"
manage: "Gerisci temi" manage: "Gestione temi"
code: "Codice tema" code: "Codice tema"
description: "Descrizione" description: "Descrizione"
installed: "{name} è installato" installed: "{name} è installato"
@ -1675,9 +1685,6 @@ _theme:
infoFg: "Testo di informazioni" infoFg: "Testo di informazioni"
infoWarnBg: "Sfondo degli avvisi" infoWarnBg: "Sfondo degli avvisi"
infoWarnFg: "Testo di avviso" infoWarnFg: "Testo di avviso"
cwBg: "Sfondo del CW"
cwFg: "Testo del pulsante CW"
cwHoverBg: "Sfondo del pulsante CW (sorvolato)"
toastBg: "Sfondo di notifica a comparsa" toastBg: "Sfondo di notifica a comparsa"
toastFg: "Testo di notifica a comparsa" toastFg: "Testo di notifica a comparsa"
buttonBg: "Sfondo del pulsante" buttonBg: "Sfondo del pulsante"
@ -1879,7 +1886,7 @@ _visibility:
followersDescription: "Visibile solo ai tuoi follower" followersDescription: "Visibile solo ai tuoi follower"
specified: "Nota diretta" specified: "Nota diretta"
specifiedDescription: "Visibile solo ai profili menzionati" specifiedDescription: "Visibile solo ai profili menzionati"
disableFederation: "Non federare" disableFederation: "Senza federazione"
disableFederationDescription: "Non spedire attività alle altre istanze remote" disableFederationDescription: "Non spedire attività alle altre istanze remote"
_postForm: _postForm:
replyPlaceholder: "Rispondi a questa nota..." replyPlaceholder: "Rispondi a questa nota..."
@ -2019,7 +2026,7 @@ _notification:
youGotReply: "{name} ti ha risposto" youGotReply: "{name} ti ha risposto"
youGotQuote: "{name} ha citato la tua Nota e ha detto" youGotQuote: "{name} ha citato la tua Nota e ha detto"
youRenoted: "{name} ha rinotato" youRenoted: "{name} ha rinotato"
youWereFollowed: "Ha iniziato a seguirti" youWereFollowed: "Adesso ti segue"
youReceivedFollowRequest: "Hai ricevuto una richiesta di follow" youReceivedFollowRequest: "Hai ricevuto una richiesta di follow"
yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata" yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata"
pollEnded: "Risultati del sondaggio." pollEnded: "Risultati del sondaggio."

View File

@ -1546,6 +1546,10 @@ _ad:
reduceFrequencyOfThisAd: "この広告の表示頻度を下げる" reduceFrequencyOfThisAd: "この広告の表示頻度を下げる"
hide: "表示しない" hide: "表示しない"
timezoneinfo: "曜日はサーバーのタイムゾーンを元に指定されます。" timezoneinfo: "曜日はサーバーのタイムゾーンを元に指定されます。"
adsSettings: "広告配信設定"
notesPerOneAd: "リアルタイム更新中に広告を配信する間隔(ノートの個数)"
setZeroToDisable: "0でリアルタイム更新時の広告配信を無効"
adsTooClose: "広告の配信間隔が極めて短いため、ユーザー体験が著しく損われる可能性があります。"
_forgotPassword: _forgotPassword:
enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。" enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。"
@ -1710,9 +1714,6 @@ _theme:
infoFg: "情報の文字" infoFg: "情報の文字"
infoWarnBg: "警告の背景" infoWarnBg: "警告の背景"
infoWarnFg: "警告の文字" infoWarnFg: "警告の文字"
cwBg: "CW ボタンの背景"
cwFg: "CW ボタンの文字"
cwHoverBg: "CW ボタンの背景 (ホバー)"
toastBg: "通知トーストの背景" toastBg: "通知トーストの背景"
toastFg: "通知トーストの文字" toastFg: "通知トーストの文字"
buttonBg: "ボタンの背景" buttonBg: "ボタンの背景"
@ -2202,3 +2203,12 @@ _moderationLogTypes:
createAd: "広告を作成" createAd: "広告を作成"
deleteAd: "広告を削除" deleteAd: "広告を削除"
updateAd: "広告を更新" updateAd: "広告を更新"
_fileViewer:
title: "ファイルの詳細"
type: "ファイルタイプ"
size: "ファイルサイズ"
url: "URL"
uploadedAt: "追加日"
attachedNotes: "添付されているノート"
thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。"

View File

@ -1649,9 +1649,6 @@ _theme:
infoFg: "情報の文字" infoFg: "情報の文字"
infoWarnBg: "警告の背景" infoWarnBg: "警告の背景"
infoWarnFg: "警告の文字" infoWarnFg: "警告の文字"
cwBg: "CW ボタンの背景"
cwFg: "CW ボタンの文字"
cwHoverBg: "CW ボタンの背景 (ホバー)"
toastBg: "通知トーストの背景" toastBg: "通知トーストの背景"
toastFg: "通知トーストの文字" toastFg: "通知トーストの文字"
buttonBg: "ボタンの背景" buttonBg: "ボタンの背景"

View File

@ -1663,9 +1663,6 @@ _theme:
infoFg: "정보창 텍스트" infoFg: "정보창 텍스트"
infoWarnBg: "경고창 배경" infoWarnBg: "경고창 배경"
infoWarnFg: "경고창 텍스트" infoWarnFg: "경고창 텍스트"
cwBg: "CW 버튼 배경"
cwFg: "CW 버튼 텍스트"
cwHoverBg: "CW 버튼 배경 (호버)"
toastBg: "알림창 배경" toastBg: "알림창 배경"
toastFg: "알림창 텍스트" toastFg: "알림창 텍스트"
buttonBg: "버튼 배경" buttonBg: "버튼 배경"

View File

@ -1043,9 +1043,6 @@ _theme:
infoFg: "Tekst informacji" infoFg: "Tekst informacji"
infoWarnBg: "Tło ostrzeżenia" infoWarnBg: "Tło ostrzeżenia"
infoWarnFg: "Tekst ostrzeżenia" infoWarnFg: "Tekst ostrzeżenia"
cwBg: "Tło CW"
cwFg: "Tekst CW"
cwHoverBg: "Tło CW (po najechaniu)"
toastBg: "Tło powiadomień" toastBg: "Tło powiadomień"
toastFg: "Tekst powiadomień" toastFg: "Tekst powiadomień"
buttonBg: "Tło przycisku" buttonBg: "Tło przycisku"

View File

@ -1551,9 +1551,6 @@ _theme:
infoFg: "Текст сообщения" infoFg: "Текст сообщения"
infoWarnBg: "Фон предупреждения" infoWarnBg: "Фон предупреждения"
infoWarnFg: "Текст предупреждения" infoWarnFg: "Текст предупреждения"
cwBg: "Фон предупреждения о содержимом"
cwFg: "Текст предупреждения о содержимом"
cwHoverBg: "Фон предупреждения о содержимом (под указателем)"
toastBg: "Фон оповещения" toastBg: "Фон оповещения"
toastFg: "Текст оповещения" toastFg: "Текст оповещения"
buttonBg: "Фон кнопки" buttonBg: "Фон кнопки"

View File

@ -1102,9 +1102,6 @@ _theme:
infoFg: "Informačný text" infoFg: "Informačný text"
infoWarnBg: "Pozadie varovania" infoWarnBg: "Pozadie varovania"
infoWarnFg: "Text varovania" infoWarnFg: "Text varovania"
cwBg: "CW pozadie tlačidla"
cwFg: "CW text tlačidla"
cwHoverBg: "CW pozadie tlačidla (pod kurzorom)"
toastBg: "Pozadie upozornenia" toastBg: "Pozadie upozornenia"
toastFg: "Text upozornenia" toastFg: "Text upozornenia"
buttonBg: "Pozadie tlačidla" buttonBg: "Pozadie tlačidla"

View File

@ -1663,9 +1663,6 @@ _theme:
infoFg: "ข้อความข้อมูล" infoFg: "ข้อความข้อมูล"
infoWarnBg: "คำเตือนพื้นหลัง" infoWarnBg: "คำเตือนพื้นหลัง"
infoWarnFg: "คำเตือนข้อความ" infoWarnFg: "คำเตือนข้อความ"
cwBg: "ปุ่ม CW พื้นหลัง"
cwFg: "ปุ่ม CW ข้อความ"
cwHoverBg: "ปุ่ม CW พื้นหลัง (โฮเวอร์)"
toastBg: "ประวัติการแจ้งเตือน" toastBg: "ประวัติการแจ้งเตือน"
toastFg: "ข้อความแจ้งเตือน" toastFg: "ข้อความแจ้งเตือน"
buttonBg: "ปุ่มพื้นหลัง" buttonBg: "ปุ่มพื้นหลัง"

View File

@ -1290,9 +1290,6 @@ _theme:
infoFg: "Текст інформації" infoFg: "Текст інформації"
infoWarnBg: "Фон попередження" infoWarnBg: "Фон попередження"
infoWarnFg: "Текст попередження" infoWarnFg: "Текст попередження"
cwBg: "Фон чутливого змісту"
cwFg: "Текст чутливого змісту"
cwHoverBg: "Фон чутливого змісту (при наведенні)"
toastBg: "Фон повідомлення" toastBg: "Фон повідомлення"
toastFg: "Текст повідомлення" toastFg: "Текст повідомлення"
buttonBg: "Фон кнопки" buttonBg: "Фон кнопки"

View File

@ -1467,9 +1467,6 @@ _theme:
infoFg: "Chữ thông tin" infoFg: "Chữ thông tin"
infoWarnBg: "Nền cảnh báo" infoWarnBg: "Nền cảnh báo"
infoWarnFg: "Chữ cảnh báo" infoWarnFg: "Chữ cảnh báo"
cwBg: "Nền nút nội dung ẩn"
cwFg: "Chữ nút nội dung ẩn"
cwHoverBg: "Nền nút nội dung ẩn (Chạm)"
toastBg: "Nền thông báo" toastBg: "Nền thông báo"
toastFg: "Chữ thông báo" toastFg: "Chữ thông báo"
buttonBg: "Nền nút" buttonBg: "Nền nút"

View File

@ -1673,9 +1673,6 @@ _theme:
infoFg: "信息文本" infoFg: "信息文本"
infoWarnBg: "警告背景" infoWarnBg: "警告背景"
infoWarnFg: "警告文本" infoWarnFg: "警告文本"
cwBg: "隐藏内容按钮背景"
cwFg: "隐藏内容按钮文本"
cwHoverBg: "隐藏内容按钮背景(悬停)"
toastBg: "Toast 通知背景" toastBg: "Toast 通知背景"
toastFg: "Toast 通知文本" toastFg: "Toast 通知文本"
buttonBg: "按钮背景" buttonBg: "按钮背景"

View File

@ -1129,6 +1129,12 @@ fileAttachedOnly: "包含附件"
showRepliesToOthersInTimeline: "在時間軸上顯示給其他人的回覆" showRepliesToOthersInTimeline: "在時間軸上顯示給其他人的回覆"
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
externalServices: "外部服務" externalServices: "外部服務"
impressum: "營運者資訊"
impressumUrl: "營運者資訊網址"
impressumDescription: "在德國與部份地區必須要明確顯示營運者資訊。"
privacyPolicy: "隱私政策"
privacyPolicyUrl: "隱私政策網址"
tosAndPrivacyPolicy: "服務條款和隱私政策"
_announcement: _announcement:
forExistingUsers: "僅限既有的使用者" forExistingUsers: "僅限既有的使用者"
forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。" forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。"
@ -1527,6 +1533,10 @@ _ad:
reduceFrequencyOfThisAd: "降低此廣告的頻率 " reduceFrequencyOfThisAd: "降低此廣告的頻率 "
hide: "隱藏" hide: "隱藏"
timezoneinfo: "星期幾是由伺服器的時區指定的。" timezoneinfo: "星期幾是由伺服器的時區指定的。"
adsSettings: "廣告投放設定"
notesPerOneAd: "即時更新中投放廣告的間隔(貼文數)"
setZeroToDisable: "設為 0 則在即時更新時不投放廣告"
adsTooClose: "由於廣告投放的間隔極短,可能會嚴重影響使用者體驗。"
_forgotPassword: _forgotPassword:
enterEmail: "請輸入您的帳戶註冊的電子郵件地址。 密碼重置連結將被發送到該電子郵件地址。" enterEmail: "請輸入您的帳戶註冊的電子郵件地址。 密碼重置連結將被發送到該電子郵件地址。"
ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。 " ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。 "
@ -1675,9 +1685,6 @@ _theme:
infoFg: "資訊內容" infoFg: "資訊內容"
infoWarnBg: "警告背景" infoWarnBg: "警告背景"
infoWarnFg: "警告文字" infoWarnFg: "警告文字"
cwBg: "隱藏內容按鈕背景"
cwFg: "隱藏內容按鈕文字"
cwHoverBg: "隱藏內容按鈕背景(懸浮)"
toastBg: "通知背景" toastBg: "通知背景"
toastFg: "通知文本" toastFg: "通知文本"
buttonBg: "按鈕背景" buttonBg: "按鈕背景"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2023.10.0-beta.6", "version": "2023.10.0",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -51,11 +51,11 @@
"typescript": "5.2.2" "typescript": "5.2.2"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "6.7.4", "@typescript-eslint/eslint-plugin": "6.7.5",
"@typescript-eslint/parser": "6.7.4", "@typescript-eslint/parser": "6.7.5",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.3.0", "cypress": "13.3.0",
"eslint": "8.50.0", "eslint": "8.51.0",
"start-server-and-test": "2.0.1" "start-server-and-test": "2.0.1"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AdsOnStream1696743032098 {
name = 'AdsOnStream1696743032098'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "notesPerOneAd" integer NOT NULL DEFAULT '0'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "notesPerOneAd"`);
}
}

View File

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UserListUserId1696807733453 {
name = 'UserListUserId1696807733453'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" ADD "userListUserId" character varying(32) NOT NULL DEFAULT ''`);
const memberships = await queryRunner.query(`SELECT "id", "userListId" FROM "user_list_membership"`);
for(let i = 0; i < memberships.length; i++) {
const userList = await queryRunner.query(`SELECT "userId" FROM "user_list" WHERE "id" = $1`, [memberships[i].userListId]);
await queryRunner.query(`UPDATE "user_list_membership" SET "userListUserId" = $1 WHERE "id" = $2`, [userList[0].userId, memberships[i].id]);
}
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" DROP COLUMN "userListUserId"`);
}
}

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UserListUserId21696808725134 {
name = 'UserListUserId21696808725134'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" ALTER COLUMN "userListUserId" DROP DEFAULT`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" ALTER COLUMN "userListUserId" SET DEFAULT ''`);
}
}

View File

@ -86,7 +86,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.2", "body-parser": "1.20.2",
"bullmq": "4.12.2", "bullmq": "4.12.3",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.1", "cbor": "9.0.1",
"chalk": "5.3.0", "chalk": "5.3.0",
@ -124,13 +124,13 @@
"nanoid": "5.0.1", "nanoid": "5.0.1",
"nested-property": "4.0.0", "nested-property": "4.0.0",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "6.9.5", "nodemailer": "6.9.6",
"nsfwjs": "2.4.2", "nsfwjs": "2.4.2",
"oauth": "0.10.0", "oauth": "0.10.0",
"oauth2orize": "1.11.1", "oauth2orize": "1.11.1",
"oauth2orize-pkce": "0.1.2", "oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14", "os-utils": "0.0.14",
"otpauth": "9.1.4", "otpauth": "9.1.5",
"parse5": "7.1.2", "parse5": "7.1.2",
"pg": "8.11.3", "pg": "8.11.3",
"pkce-challenge": "4.0.1", "pkce-challenge": "4.0.1",
@ -189,13 +189,13 @@
"@types/jsrsasign": "10.5.9", "@types/jsrsasign": "10.5.9",
"@types/mime-types": "2.1.2", "@types/mime-types": "2.1.2",
"@types/ms": "0.7.32", "@types/ms": "0.7.32",
"@types/node": "20.8.2", "@types/node": "20.8.4",
"@types/node-fetch": "3.0.3", "@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.11", "@types/nodemailer": "6.4.11",
"@types/oauth": "0.9.2", "@types/oauth": "0.9.2",
"@types/oauth2orize": "1.11.1", "@types/oauth2orize": "1.11.1",
"@types/oauth2orize-pkce": "0.1.0", "@types/oauth2orize-pkce": "0.1.0",
"@types/pg": "8.10.3", "@types/pg": "8.10.4",
"@types/pug": "2.0.7", "@types/pug": "2.0.7",
"@types/punycode": "2.1.0", "@types/punycode": "2.1.0",
"@types/qrcode": "1.5.2", "@types/qrcode": "1.5.2",
@ -212,11 +212,11 @@
"@types/vary": "1.1.1", "@types/vary": "1.1.1",
"@types/web-push": "3.6.1", "@types/web-push": "3.6.1",
"@types/ws": "8.5.6", "@types/ws": "8.5.6",
"@typescript-eslint/eslint-plugin": "6.7.4", "@typescript-eslint/eslint-plugin": "6.7.5",
"@typescript-eslint/parser": "6.7.4", "@typescript-eslint/parser": "6.7.5",
"aws-sdk-client-mock": "3.0.0", "aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.50.0", "eslint": "8.51.0",
"eslint-plugin-import": "2.28.1", "eslint-plugin-import": "2.28.1",
"execa": "8.0.1", "execa": "8.0.1",
"jest": "29.7.0", "jest": "29.7.0",

View File

@ -17,7 +17,6 @@ export async function server() {
const app = await NestFactory.createApplicationContext(MainModule, { const app = await NestFactory.createApplicationContext(MainModule, {
logger: new NestLogger(), logger: new NestLogger(),
}); });
app.enableShutdownHooks();
const serverService = app.get(ServerService); const serverService = app.get(ServerService);
await serverService.launch(); await serverService.launch();
@ -35,7 +34,6 @@ export async function jobQueue() {
const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, { const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, {
logger: new NestLogger(), logger: new NestLogger(),
}); });
jobQueue.enableShutdownHooks();
jobQueue.get(QueueProcessorService).start(); jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start(); jobQueue.get(ChartManagementService).start();

View File

@ -228,7 +228,7 @@ export class AccountMoveService {
}, },
}).then(memberships => memberships.map(membership => membership.userListId)); }).then(memberships => memberships.map(membership => membership.userListId));
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map(); const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; userListUserId: string; }> = new Map();
// 重複しないようにIDを生成 // 重複しないようにIDを生成
const genId = (): string => { const genId = (): string => {
@ -244,6 +244,7 @@ export class AccountMoveService {
createdAt: new Date(), createdAt: new Date(),
userId: dst.id, userId: dst.id,
userListId: membership.userListId, userListId: membership.userListId,
userListUserId: membership.userListUserId,
}); });
} }

View File

@ -16,6 +16,7 @@ import type { AntennasRepository, UserListMembershipsRepository } from '@/models
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
@ -38,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown {
private utilityService: UtilityService, private utilityService: UtilityService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private redisTimelineService: RedisTimelineService,
) { ) {
this.antennasFetched = false; this.antennasFetched = false;
this.antennas = []; this.antennas = [];
@ -84,12 +86,7 @@ export class AntennaService implements OnApplicationShutdown {
const redisPipeline = this.redisForTimelines.pipeline(); const redisPipeline = this.redisForTimelines.pipeline();
for (const antenna of matchedAntennas) { for (const antenna of matchedAntennas) {
redisPipeline.xadd( this.redisTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline);
`antennaTimeline:${antenna.id}`,
'MAXLEN', '~', '200',
'*',
'note', note.id);
this.globalEventService.publishAntennaStream(antenna.id, 'note', note); this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
} }

View File

@ -61,6 +61,7 @@ import { FileInfoService } from './FileInfoService.js';
import { SearchService } from './SearchService.js'; import { SearchService } from './SearchService.js';
import { ClipService } from './ClipService.js'; import { ClipService } from './ClipService.js';
import { FeaturedService } from './FeaturedService.js'; import { FeaturedService } from './FeaturedService.js';
import { RedisTimelineService } from './RedisTimelineService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js'; import FederationChart from './chart/charts/federation.js';
import NotesChart from './chart/charts/notes.js'; import NotesChart from './chart/charts/notes.js';
@ -189,6 +190,7 @@ const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: Fi
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
const $RedisTimelineService: Provider = { provide: 'RedisTimelineService', useExisting: RedisTimelineService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@ -321,6 +323,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
SearchService, SearchService,
ClipService, ClipService,
FeaturedService, FeaturedService,
RedisTimelineService,
ChartLoggerService, ChartLoggerService,
FederationChart, FederationChart,
NotesChart, NotesChart,
@ -446,6 +449,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$SearchService, $SearchService,
$ClipService, $ClipService,
$FeaturedService, $FeaturedService,
$RedisTimelineService,
$ChartLoggerService, $ChartLoggerService,
$FederationChart, $FederationChart,
$NotesChart, $NotesChart,
@ -572,6 +576,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
SearchService, SearchService,
ClipService, ClipService,
FeaturedService, FeaturedService,
RedisTimelineService,
FederationChart, FederationChart,
NotesChart, NotesChart,
UsersChart, UsersChart,
@ -696,6 +701,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$SearchService, $SearchService,
$ClipService, $ClipService,
$FeaturedService, $FeaturedService,
$RedisTimelineService,
$FederationChart, $FederationChart,
$NotesChart, $NotesChart,
$UsersChart, $UsersChart,

View File

@ -43,16 +43,16 @@ export class FeaturedService {
} }
@bindThis @bindThis
private async getRankingOf(name: string, windowRange: number, limit: number): Promise<string[]> { private async getRankingOf(name: string, windowRange: number, threshold: number): Promise<string[]> {
const currentWindow = this.getCurrentWindow(windowRange); const currentWindow = this.getCurrentWindow(windowRange);
const previousWindow = currentWindow - 1; const previousWindow = currentWindow - 1;
const [currentRankingResult, previousRankingResult] = await Promise.all([ const redisPipeline = this.redisClient.pipeline();
this.redisClient.zrange( redisPipeline.zrange(
`${name}:${currentWindow}`, 0, limit, 'REV', 'WITHSCORES'), `${name}:${currentWindow}`, 0, threshold, 'REV', 'WITHSCORES');
this.redisClient.zrange( redisPipeline.zrange(
`${name}:${previousWindow}`, 0, limit, 'REV', 'WITHSCORES'), `${name}:${previousWindow}`, 0, threshold, 'REV', 'WITHSCORES');
]); const [currentRankingResult, previousRankingResult] = await redisPipeline.exec().then(result => result ? result.map(r => r[1] as string[]) : [[], []]);
const ranking = new Map<string, number>(); const ranking = new Map<string, number>();
for (let i = 0; i < currentRankingResult.length; i += 2) { for (let i = 0; i < currentRankingResult.length; i += 2) {
@ -95,22 +95,22 @@ export class FeaturedService {
} }
@bindThis @bindThis
public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> { public getGlobalNotesRanking(threshold: number): Promise<MiNote['id'][]> {
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit); return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold);
} }
@bindThis @bindThis
public getInChannelNotesRanking(channelId: MiNote['channelId'], limit: number): Promise<MiNote['id'][]> { public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise<MiNote['id'][]> {
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, limit); return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold);
} }
@bindThis @bindThis
public getPerUserNotesRanking(userId: MiUser['id'], limit: number): Promise<MiNote['id'][]> { public getPerUserNotesRanking(userId: MiUser['id'], threshold: number): Promise<MiNote['id'][]> {
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, limit); return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, threshold);
} }
@bindThis @bindThis
public getHashtagsRanking(limit: number): Promise<string[]> { public getHashtagsRanking(threshold: number): Promise<string[]> {
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, limit); return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, threshold);
} }
} }

View File

@ -174,16 +174,15 @@ export class HashtagService {
const redisPipeline = this.redisClient.pipeline(); const redisPipeline = this.redisClient.pipeline();
// TODO: これらの Set は Bloom Filter を使うようにしても良さそう
// チャート用 // チャート用
redisPipeline.sadd(`hashtagUsers:${hashtag}:${window}`, userId); redisPipeline.pfadd(`hashtagUsers:${hashtag}:${window}`, userId);
redisPipeline.expire(`hashtagUsers:${hashtag}:${window}`, redisPipeline.expire(`hashtagUsers:${hashtag}:${window}`,
60 * 60 * 24 * 3, // 3日間 60 * 60 * 24 * 3, // 3日間
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定 'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
); );
// ユニークカウント用 // ユニークカウント用
// TODO: Bloom Filter を使うようにしても良さそう
redisPipeline.sadd(`hashtagUsers:${hashtag}`, userId); redisPipeline.sadd(`hashtagUsers:${hashtag}`, userId);
redisPipeline.expire(`hashtagUsers:${hashtag}`, redisPipeline.expire(`hashtagUsers:${hashtag}`,
60 * 60, // 1時間 60 * 60, // 1時間
@ -202,7 +201,7 @@ export class HashtagService {
for (let i = 0; i < range; i++) { for (let i = 0; i < range; i++) {
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`; const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`); redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`);
now.setMinutes(now.getMinutes() - (i * 10), 0, 0); now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
} }
@ -223,7 +222,7 @@ export class HashtagService {
for (let i = 0; i < range; i++) { for (let i = 0; i < range; i++) {
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`; const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
for (const hashtag of hashtags) { for (const hashtag of hashtags) {
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`); redisPipeline.pfcount(`hashtagUsers:${hashtag}:${window}`);
} }
now.setMinutes(now.getMinutes() - (i * 10), 0, 0); now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
} }

View File

@ -54,6 +54,7 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { FeaturedService } from '@/core/FeaturedService.js'; import { FeaturedService } from '@/core/FeaturedService.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -194,6 +195,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private idService: IdService, private idService: IdService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private queueService: QueueService, private queueService: QueueService,
private redisTimelineService: RedisTimelineService,
private noteReadService: NoteReadService, private noteReadService: NoteReadService,
private notificationService: NotificationService, private notificationService: NotificationService,
private relayService: RelayService, private relayService: RelayService,
@ -347,14 +349,6 @@ export class NoteCreateService implements OnApplicationShutdown {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
if (data.channel) {
this.redisForTimelines.xadd(
`channelTimeline:${data.channel.id}`,
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
'*',
'note', note.id);
}
setImmediate('post created', { signal: this.#shutdownController.signal }).then( setImmediate('post created', { signal: this.#shutdownController.signal }).then(
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ }, () => { /* aborted, ignore this */ },
@ -494,11 +488,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// Increment notes count (user) // Increment notes count (user)
this.incNotesCountOfUser(user); this.incNotesCountOfUser(user);
if (data.visibility === 'specified') { this.pushToTl(note, user);
// TODO?
} else {
this.pushToTl(note, user);
}
this.antennaService.addNoteToAntennas(note, user); this.antennaService.addNoteToAntennas(note, user);
@ -828,14 +818,12 @@ export class NoteCreateService implements OnApplicationShutdown {
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
const redisPipeline = this.redisForTimelines.pipeline(); const r = this.redisForTimelines.pipeline();
if (note.channelId) { if (note.channelId) {
redisPipeline.xadd( this.redisTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
`userTimelineWithChannel:${user.id}`,
'MAXLEN', '~', note.userHost == null ? meta.perLocalUserUserTimelineCacheMax.toString() : meta.perRemoteUserUserTimelineCacheMax.toString(), this.redisTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
'*',
'note', note.id);
const channelFollowings = await this.channelFollowingsRepository.find({ const channelFollowings = await this.channelFollowingsRepository.find({
where: { where: {
@ -845,138 +833,90 @@ export class NoteCreateService implements OnApplicationShutdown {
}); });
for (const channelFollowing of channelFollowings) { for (const channelFollowing of channelFollowings) {
redisPipeline.xadd( this.redisTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
`homeTimeline:${channelFollowing.followerId}`,
'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(),
'*',
'note', note.id);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
redisPipeline.xadd( this.redisTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
`homeTimelineWithFiles:${channelFollowing.followerId}`,
'MAXLEN', '~', (meta.perUserHomeTimelineCacheMax / 2).toString(),
'*',
'note', note.id);
} }
} }
} else { } else {
// TODO: キャッシュ? // TODO: キャッシュ?
const followings = await this.followingsRepository.find({ // eslint-disable-next-line prefer-const
where: { let [followings, userListMemberships] = await Promise.all([
followeeId: user.id, this.followingsRepository.find({
followerHost: IsNull(), where: {
isFollowerHibernated: false, followeeId: user.id,
}, followerHost: IsNull(),
select: ['followerId', 'withReplies'], isFollowerHibernated: false,
}); },
select: ['followerId', 'withReplies'],
}),
this.userListMembershipsRepository.find({
where: {
userId: user.id,
},
select: ['userListId', 'userListUserId', 'withReplies'],
}),
]);
const userListMemberships = await this.userListMembershipsRepository.find({ if (note.visibility === 'followers') {
where: { // TODO: 重そうだから何とかしたい Set 使う?
userId: user.id, userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId));
}, }
select: ['userListId', 'withReplies'],
});
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) { for (const following of followings) {
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
// 自分自身以外への返信 // 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) { if (note.replyId && note.replyUserId !== note.userId) {
if (!following.withReplies) continue; if (!following.withReplies) continue;
} }
redisPipeline.xadd( this.redisTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
`homeTimeline:${following.followerId}`,
'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(),
'*',
'note', note.id);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
redisPipeline.xadd( this.redisTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
`homeTimelineWithFiles:${following.followerId}`,
'MAXLEN', '~', (meta.perUserHomeTimelineCacheMax / 2).toString(),
'*',
'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) { for (const userListMembership of userListMemberships) {
// ダイレクトのとき、そのリストが対象外のユーザーの場合
if (
note.visibility === 'specified' &&
!note.visibleUserIds.some(v => v === userListMembership.userListUserId)
) continue;
// 自分自身以外への返信 // 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) { if (note.replyId && note.replyUserId !== note.userId) {
if (!userListMembership.withReplies) continue; if (!userListMembership.withReplies) continue;
} }
redisPipeline.xadd( this.redisTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
`userListTimeline:${userListMembership.userListId}`,
'MAXLEN', '~', meta.perUserListTimelineCacheMax.toString(),
'*',
'note', note.id);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
redisPipeline.xadd( this.redisTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
`userListTimelineWithFiles:${userListMembership.userListId}`,
'MAXLEN', '~', (meta.perUserListTimelineCacheMax / 2).toString(),
'*',
'note', note.id);
} }
} }
{ // 自分自身のHTL if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
redisPipeline.xadd( this.redisTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
`homeTimeline:${user.id}`,
'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(),
'*',
'note', note.id);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
redisPipeline.xadd( this.redisTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
`homeTimelineWithFiles:${user.id}`,
'MAXLEN', '~', (meta.perUserHomeTimelineCacheMax / 2).toString(),
'*',
'note', note.id);
} }
} }
// 自分自身以外への返信 // 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) { if (note.replyId && note.replyUserId !== note.userId) {
redisPipeline.xadd( this.redisTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
`userTimelineWithReplies:${user.id}`,
'MAXLEN', '~', note.userHost == null ? meta.perLocalUserUserTimelineCacheMax.toString() : meta.perRemoteUserUserTimelineCacheMax.toString(),
'*',
'note', note.id);
} else { } else {
redisPipeline.xadd( this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
`userTimeline:${user.id}`,
'MAXLEN', '~', note.userHost == null ? meta.perLocalUserUserTimelineCacheMax.toString() : meta.perRemoteUserUserTimelineCacheMax.toString(),
'*',
'note', note.id);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
redisPipeline.xadd( this.redisTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
`userTimelineWithFiles:${user.id}`,
'MAXLEN', '~', note.userHost == null ? (meta.perLocalUserUserTimelineCacheMax / 2).toString() : (meta.perRemoteUserUserTimelineCacheMax / 2).toString(),
'*',
'note', note.id);
} }
if (note.visibility === 'public' && note.userHost == null) { if (note.visibility === 'public' && note.userHost == null) {
redisPipeline.xadd( this.redisTimelineService.push('localTimeline', note.id, 1000, r);
'localTimeline',
'MAXLEN', '~', '1000',
'*',
'note', note.id);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {
redisPipeline.xadd( this.redisTimelineService.push('localTimelineWithFiles', note.id, 500, r);
'localTimelineWithFiles',
'MAXLEN', '~', '500',
'*',
'note', note.id);
} }
} }
} }
@ -988,7 +928,7 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
} }
redisPipeline.exec(); r.exec();
} }
@bindThis @bindThis

View File

@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, 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 { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import type { SelectQueryBuilder } from 'typeorm'; import type { SelectQueryBuilder } from 'typeorm';
@Injectable() @Injectable()
@ -34,6 +35,8 @@ export class QueryService {
@Inject(DI.renoteMutingsRepository) @Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository, private renoteMutingsRepository: RenoteMutingsRepository,
private idService: IdService,
) { ) {
} }
@ -49,15 +52,15 @@ export class QueryService {
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.id`, 'DESC'); q.orderBy(`${q.alias}.id`, 'DESC');
} else if (sinceDate && untilDate) { } else if (sinceDate && untilDate) {
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.genId(new Date(sinceDate)) });
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.genId(new Date(untilDate)) });
q.orderBy(`${q.alias}.createdAt`, 'DESC'); q.orderBy(`${q.alias}.id`, 'DESC');
} else if (sinceDate) { } else if (sinceDate) {
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.genId(new Date(sinceDate)) });
q.orderBy(`${q.alias}.createdAt`, 'ASC'); q.orderBy(`${q.alias}.id`, 'ASC');
} else if (untilDate) { } else if (untilDate) {
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.genId(new Date(untilDate)) });
q.orderBy(`${q.alias}.createdAt`, 'DESC'); q.orderBy(`${q.alias}.id`, 'DESC');
} else { } else {
q.orderBy(`${q.alias}.id`, 'DESC'); q.orderBy(`${q.alias}.id`, 'DESC');
} }
@ -76,13 +79,15 @@ export class QueryService {
// 投稿の引用元の作者にブロックされていない // 投稿の引用元の作者にブロックされていない
q q
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('note.replyUserId IS NULL') qb
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); .where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('note.renoteUserId IS NULL') qb
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); .where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
})); }));
q.setParameters(blockingQuery.getParameters()); q.setParameters(blockingQuery.getParameters());
@ -112,16 +117,17 @@ export class QueryService {
.where('threadMuted.userId = :userId', { userId: me.id }); .where('threadMuted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.andWhere(new Brackets(qb => { qb q.andWhere(new Brackets(qb => {
.where('note.threadId IS NULL') qb
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); .where('note.threadId IS NULL')
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
})); }));
q.setParameters(mutedQuery.getParameters()); q.setParameters(mutedQuery.getParameters());
} }
@bindThis @bindThis
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: MiUser): void { public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId') .select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id }); .where('muting.muterId = :muterId', { muterId: me.id });
@ -139,26 +145,31 @@ export class QueryService {
// 投稿の引用元の作者をミュートしていない // 投稿の引用元の作者をミュートしていない
q q
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('note.replyUserId IS NULL') qb
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); .where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('note.renoteUserId IS NULL') qb
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); .where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
})) }))
// mute instances // mute instances
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.andWhere('note.userHost IS NULL') qb
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); .andWhere('note.userHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('note.replyUserHost IS NULL') qb
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); .where('note.replyUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('note.renoteUserHost IS NULL') qb
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); .where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
})); }));
q.setParameters(mutingQuery.getParameters()); q.setParameters(mutingQuery.getParameters());
@ -180,36 +191,41 @@ export class QueryService {
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void { public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
// This code must always be synchronized with the checks in Notes.isVisibleForMe. // This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) { if (me == null) {
q.andWhere(new Brackets(qb => { qb q.andWhere(new Brackets(qb => {
.where('note.visibility = \'public\'') qb
.orWhere('note.visibility = \'home\''); .where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\'');
})); }));
} else { } else {
const followingQuery = this.followingsRepository.createQueryBuilder('following') const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId') .select('following.followeeId')
.where('following.followerId = :meId'); .where('following.followerId = :meId');
q.andWhere(new Brackets(qb => { qb q.andWhere(new Brackets(qb => {
qb
// 公開投稿である // 公開投稿である
.where(new Brackets(qb => { qb .where(new Brackets(qb => {
.where('note.visibility = \'public\'') qb
.orWhere('note.visibility = \'home\''); .where('note.visibility = \'public\'')
})) .orWhere('note.visibility = \'home\'');
}))
// または 自分自身 // または 自分自身
.orWhere('note.userId = :meId') .orWhere('note.userId = :meId')
// または 自分宛て // または 自分宛て
.orWhere(':meId = ANY(note.visibleUserIds)') .orWhere(':meId = ANY(note.visibleUserIds)')
.orWhere(':meId = ANY(note.mentions)') .orWhere(':meId = ANY(note.mentions)')
.orWhere(new Brackets(qb => { qb .orWhere(new Brackets(qb => {
// または フォロワー宛ての投稿であり、 qb
.where('note.visibility = \'followers\'') // または フォロワー宛ての投稿であり、
.andWhere(new Brackets(qb => { qb .where('note.visibility = \'followers\'')
// 自分がフォロワーである .andWhere(new Brackets(qb => {
.where(`note.userId IN (${ followingQuery.getQuery() })`) qb
// または 自分の投稿へのリプライ // 自分がフォロワーである
.orWhere('note.replyUserId = :meId'); .where(`note.userId IN (${ followingQuery.getQuery() })`)
// または 自分の投稿へのリプライ
.orWhere('note.replyUserId = :meId');
}));
})); }));
}));
})); }));
q.setParameters({ meId: me.id }); q.setParameters({ meId: me.id });

View File

@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
@Injectable()
export class RedisTimelineService {
constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
private idService: IdService,
) {
}
@bindThis
public push(tl: string, id: string, maxlen: number, pipeline: Redis.ChainableCommander) {
// リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、
// 3分以内に投稿されたものでない場合、Redisにある最古のIDより新しい場合のみ追加する
if (this.idService.parse(id).date.getTime() > Date.now() - 1000 * 60 * 3) {
pipeline.lpush('list:' + tl, id);
if (Math.random() < 0.1) { // 10%の確率でトリム
pipeline.ltrim('list:' + tl, 0, maxlen - 1);
}
} else {
// 末尾のIDを取得
this.redisForTimelines.lindex('list:' + tl, -1).then(lastId => {
if (lastId == null || (this.idService.parse(id).date.getTime() > this.idService.parse(lastId).date.getTime())) {
this.redisForTimelines.lpush('list:' + tl, id);
} else {
Promise.resolve();
}
});
}
}
@bindThis
public get(name: string, untilId?: string | null, sinceId?: string | null) {
if (untilId && sinceId) {
return this.redisForTimelines.lrange('list:' + name, 0, -1)
.then(ids => ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1));
} else if (untilId) {
return this.redisForTimelines.lrange('list:' + name, 0, -1)
.then(ids => ids.filter(id => id < untilId).sort((a, b) => a > b ? -1 : 1));
} else if (sinceId) {
return this.redisForTimelines.lrange('list:' + name, 0, -1)
.then(ids => ids.filter(id => id > sinceId).sort((a, b) => a < b ? -1 : 1));
} else {
return this.redisForTimelines.lrange('list:' + name, 0, -1)
.then(ids => ids.sort((a, b) => a > b ? -1 : 1));
}
}
@bindThis
public getMulti(name: string[], untilId?: string | null, sinceId?: string | null): Promise<string[][]> {
const pipeline = this.redisForTimelines.pipeline();
for (const n of name) {
pipeline.lrange('list:' + n, 0, -1);
}
return pipeline.exec().then(res => {
if (res == null) return [];
const tls = res.map(r => r[1] as string[]);
return tls.map(ids =>
(untilId && sinceId)
? ids.filter(id => id < untilId && id > sinceId).sort((a, b) => a > b ? -1 : 1)
: untilId
? ids.filter(id => id < untilId).sort((a, b) => a > b ? -1 : 1)
: sinceId
? ids.filter(id => id > sinceId).sort((a, b) => a < b ? -1 : 1)
: ids.sort((a, b) => a > b ? -1 : 1),
);
});
}
}

View File

@ -20,6 +20,7 @@ import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = { export type RolePolicies = {
@ -102,6 +103,7 @@ export class RoleService implements OnApplicationShutdown {
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private idService: IdService, private idService: IdService,
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private redisTimelineService: RedisTimelineService,
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
@ -472,12 +474,7 @@ export class RoleService implements OnApplicationShutdown {
const redisPipeline = this.redisClient.pipeline(); const redisPipeline = this.redisClient.pipeline();
for (const role of roles) { for (const role of roles) {
redisPipeline.xadd( this.redisTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline);
`roleTimeline:${role.id}`,
'MAXLEN', '~', '1000',
'*',
'note', note.id);
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
} }

View File

@ -97,6 +97,7 @@ export class UserListService implements OnApplicationShutdown {
createdAt: new Date(), createdAt: new Date(),
userId: target.id, userId: target.id,
userListId: list.id, userListId: list.id,
userListUserId: list.userId,
} as MiUserListMembership); } as MiUserListMembership);
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id }); this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });

View File

@ -17,6 +17,7 @@ import type { MiNoteReaction } from '@/models/NoteReaction.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isNotNull } from '@/misc/is-not-null.js'; import { isNotNull } from '@/misc/is-not-null.js';
import { DebounceLoader } from '@/misc/loader.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js'; import type { ReactionService } from '../ReactionService.js';
@ -29,6 +30,7 @@ export class NoteEntityService implements OnModuleInit {
private driveFileEntityService: DriveFileEntityService; private driveFileEntityService: DriveFileEntityService;
private customEmojiService: CustomEmojiService; private customEmojiService: CustomEmojiService;
private reactionService: ReactionService; private reactionService: ReactionService;
private noteLoader = new DebounceLoader(this.findNoteOrFail);
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@ -285,7 +287,7 @@ export class NoteEntityService implements OnModuleInit {
}, options); }, options);
const meId = me ? me.id : null; const meId = me ? me.id : null;
const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] }); const note = typeof src === 'object' ? src : await this.noteLoader.load(src);
const host = note.userHost; const host = note.userHost;
let text = note.text; let text = note.text;
@ -450,4 +452,12 @@ export class NoteEntityService implements OnModuleInit {
} }
return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[];
} }
@bindThis
private findNoteOrFail(id: string): Promise<MiNote> {
return this.notesRepository.findOneOrFail({
where: { id },
relations: ['user'],
});
}
} }

View File

@ -33,9 +33,10 @@ export class RoleEntityService {
const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign')
.where('assign.roleId = :roleId', { roleId: role.id }) .where('assign.roleId = :roleId', { roleId: role.id })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('assign.expiresAt IS NULL') qb
.orWhere('assign.expiresAt > :now', { now: new Date() }); .where('assign.expiresAt IS NULL')
.orWhere('assign.expiresAt > :now', { now: new Date() });
})) }))
.getCount(); .getCount();

View File

@ -0,0 +1,52 @@
export type FetchFunction<K, V> = (key: K) => Promise<V>;
type ResolveReject<V> = Parameters<ConstructorParameters<typeof Promise<V>>[0]>;
type ResolverPair<V> = {
resolve: ResolveReject<V>[0];
reject: ResolveReject<V>[1];
};
export class DebounceLoader<K, V> {
private resolverMap = new Map<K, ResolverPair<V>>();
private promiseMap = new Map<K, Promise<V>>();
private resolvedPromise = Promise.resolve();
constructor(private loadFn: FetchFunction<K, V>) {}
public load(key: K): Promise<V> {
const promise = this.promiseMap.get(key);
if (typeof promise !== 'undefined') {
return promise;
}
const isFirst = this.promiseMap.size === 0;
const newPromise = new Promise<V>((resolve, reject) => {
this.resolverMap.set(key, { resolve, reject });
});
this.promiseMap.set(key, newPromise);
if (isFirst) {
this.enqueueDebouncedLoadJob();
}
return newPromise;
}
private runDebouncedLoad(): void {
const resolvers = [...this.resolverMap];
this.resolverMap.clear();
this.promiseMap.clear();
for (const [key, { resolve, reject }] of resolvers) {
this.loadFn(key).then(resolve, reject);
}
}
private enqueueDebouncedLoadJob(): void {
this.resolvedPromise.then(() => {
process.nextTick(() => {
this.runDebouncedLoad();
});
});
}
}

View File

@ -503,4 +503,9 @@ export class MiMeta {
default: 300, default: 300,
}) })
public perUserListTimelineCacheMax: number; public perUserListTimelineCacheMax: number;
@Column('integer', {
default: 0,
})
public notesPerOneAd: number;
} }

View File

@ -50,4 +50,11 @@ export class MiUserListMembership {
default: false, default: false,
}) })
public withReplies: boolean; public withReplies: boolean;
//#region Denormalized fields
@Column({
...id(),
})
public userListUserId: MiUser['id'];
//#endregion
} }

View File

@ -379,9 +379,10 @@ export class ActivityPubServerService {
if (page) { if (page) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
.andWhere('note.userId = :userId', { userId: user.id }) .andWhere('note.userId = :userId', { userId: user.id })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('note.visibility = \'public\'') qb
.orWhere('note.visibility = \'home\''); .where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\'');
})) }))
.andWhere('note.localOnly = FALSE'); .andWhere('note.localOnly = FALSE');

View File

@ -135,7 +135,11 @@ export class NodeinfoServerService {
.type( .type(
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"',
) )
.header('Cache-Control', 'public, max-age=600'); .header('Cache-Control', 'public, max-age=600')
.header('Access-Control-Allow-Headers', 'Accept')
.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
.header('Access-Control-Allow-Origin', '*')
.header('Access-Control-Expose-Headers', 'Vary');
return { version: '2.1', ...base }; return { version: '2.1', ...base };
}); });
@ -148,7 +152,11 @@ export class NodeinfoServerService {
.type( .type(
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"',
) )
.header('Cache-Control', 'public, max-age=600'); .header('Cache-Control', 'public, max-age=600')
.header('Access-Control-Allow-Headers', 'Accept')
.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
.header('Access-Control-Allow-Origin', '*')
.header('Access-Control-Expose-Headers', 'Vary');
return { version: '2.0', ...base }; return { version: '2.0', ...base };
}); });

View File

@ -199,10 +199,10 @@ export class ServerService implements OnApplicationShutdown {
includeSecrets: true, includeSecrets: true,
})); }));
reply.code(200); reply.code(200).send('Verification succeeded! メールアドレスの認証に成功しました。');
return 'Verify succeeded!'; return;
} else { } else {
reply.code(404); reply.code(404).send('Verification failed. Please try again. メールアドレスの認証に失敗しました。もう一度お試しください');
return; return;
} }
}); });

View File

@ -297,6 +297,10 @@ export const meta = {
type: 'number', type: 'number',
optional: false, nullable: false, optional: false, nullable: false,
}, },
notesPerOneAd: {
type: 'number',
optional: false, nullable: false,
},
}, },
}, },
} as const; } as const;
@ -408,6 +412,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax,
notesPerOneAd: instance.notesPerOneAd,
}; };
}); });
} }

View File

@ -61,9 +61,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id }) .andWhere('assign.roleId = :roleId', { roleId: role.id })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('assign.expiresAt IS NULL') qb
.orWhere('assign.expiresAt > :now', { now: new Date() }); .where('assign.expiresAt IS NULL')
.orWhere('assign.expiresAt > :now', { now: new Date() });
})) }))
.innerJoinAndSelect('assign.user', 'user'); .innerJoinAndSelect('assign.user', 'user');

View File

@ -114,6 +114,7 @@ export const paramDef = {
perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perRemoteUserUserTimelineCacheMax: { type: 'integer' },
perUserHomeTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' },
perUserListTimelineCacheMax: { type: 'integer' }, perUserListTimelineCacheMax: { type: 'integer' },
notesPerOneAd: { type: 'integer' },
}, },
required: [], required: [],
} as const; } as const;
@ -471,6 +472,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax; set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax;
} }
if (ps.notesPerOneAd !== undefined) {
set.notesPerOneAd = ps.notesPerOneAd;
}
const before = await this.metaService.fetch(true); const before = await this.metaService.fetch(true);
await this.metaService.update(set); await this.metaService.update(set);

View File

@ -12,6 +12,7 @@ import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -69,8 +70,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private noteReadService: NoteReadService, private noteReadService: NoteReadService,
private redisTimelineService: RedisTimelineService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
const antenna = await this.antennasRepository.findOneBy({ const antenna = await this.antennasRepository.findOneBy({
id: ps.antennaId, id: ps.antennaId,
userId: me.id, userId: me.id,
@ -85,19 +90,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
lastUsedAt: new Date(), lastUsedAt: new Date(),
}); });
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 let noteIds = await this.redisTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId);
const noteIdsRes = await this.redisForTimelines.xrevrange( noteIds = noteIds.slice(0, ps.limit);
`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 ?? '-',
'COUNT', limit);
if (noteIdsRes.length === 0) {
return [];
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
if (noteIds.length === 0) { if (noteIds.length === 0) {
return []; return [];
} }
@ -115,7 +109,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me);
const notes = await query.getMany(); const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1); if (sinceId != null && untilId == null) {
notes.sort((a, b) => a.id < b.id ? -1 : 1);
} else {
notes.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (notes.length > 0) { if (notes.length > 0) {
this.noteReadService.read(me.id, notes); this.noteReadService.read(me.id, notes);

View File

@ -55,9 +55,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.query !== '') { if (ps.query !== '') {
if (ps.type === 'nameAndDescription') { if (ps.type === 'nameAndDescription') {
query.andWhere(new Brackets(qb => { qb query.andWhere(new Brackets(qb => {
.where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) qb
.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
})); }));
} else { } else {
query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });

View File

@ -12,6 +12,9 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -66,9 +69,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService, private idService: IdService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private redisTimelineService: RedisTimelineService,
private cacheService: CacheService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
const isRangeSpecified = untilId != null && sinceId != null;
const channel = await this.channelsRepository.findOneBy({ const channel = await this.channelsRepository.findOneBy({
id: ps.channelId, id: ps.channelId,
}); });
@ -77,68 +86,66 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchChannel); throw new ApiError(meta.errors.noSuchChannel);
} }
let timeline: MiNote[] = [];
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisForTimelines.xrevrange(
`channelTimeline:${channel.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit);
}
// redis から取得していないとき・取得数が足りないとき
if (noteIdsRes.length < limit) {
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.channelId = :channelId', { channelId: channel.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
timeline = await query.limit(ps.limit).getMany();
} else {
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
if (noteIds.length === 0) {
return [];
}
//#region Construct query
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('note.channel', 'channel');
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
timeline = await query.getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (me) this.activeUsersChart.read(me); if (me) this.activeUsersChart.read(me);
if (isRangeSpecified || sinceId == null) {
const [
userIdsWhoMeMuting,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
let noteIds = await this.redisTimelineService.get(`channelTimeline:${channel.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length > 0) {
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('note.channel', 'channel');
let timeline = await query.getMany();
timeline = timeline.filter(note => {
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
return true;
});
// TODO: フィルタで件数が減った場合の埋め合わせ処理
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
if (timeline.length > 0) {
return await this.noteEntityService.packMany(timeline, me);
}
}
}
//#region fallback to database
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.channelId = :channelId', { channelId: channel.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
//#endregion
}); });
} }
} }

View File

@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, DriveFilesRepository } from '@/models/_.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
@ -41,6 +42,9 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
fileId: { type: 'string', format: 'misskey:id' }, fileId: { type: 'string', format: 'misskey:id' },
}, },
required: ['fileId'], required: ['fileId'],
@ -56,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
// Fetch file // Fetch file
@ -68,9 +73,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchFile); throw new ApiError(meta.errors.noSuchFile);
} }
const notes = await this.notesRepository.createQueryBuilder('note') const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId);
.where(':file = ANY(note.fileIds)', { file: file.id }) query.andWhere(':file = ANY(note.fileIds)', { file: file.id });
.getMany();
const notes = await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(notes, me, { return await this.noteEntityService.packMany(notes, me, {
detail: true, detail: true,

View File

@ -181,6 +181,11 @@ export const meta = {
}, },
}, },
}, },
notesPerOneAd: {
type: 'number',
optional: false, nullable: false,
default: 0,
},
requireSetup: { requireSetup: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
@ -331,6 +336,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
imageUrl: ad.imageUrl, imageUrl: ad.imageUrl,
dayOfWeek: ad.dayOfWeek, dayOfWeek: ad.dayOfWeek,
})), })),
notesPerOneAd: instance.notesPerOneAd,
enableEmail: instance.enableEmail, enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker, enableServiceWorker: instance.enableServiceWorker,

View File

@ -49,16 +49,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('note.replyId = :noteId', { noteId: ps.noteId }) qb
.orWhere(new Brackets(qb => { qb .where('note.replyId = :noteId', { noteId: ps.noteId })
.where('note.renoteId = :noteId', { noteId: ps.noteId }) .orWhere(new Brackets(qb => {
.andWhere(new Brackets(qb => { qb qb
.where('note.text IS NOT NULL') .where('note.renoteId = :noteId', { noteId: ps.noteId })
.orWhere('note.fileIds != \'{}\'') .andWhere(new Brackets(qb => {
.orWhere('note.hasPoll = TRUE'); qb
.where('note.text IS NOT NULL')
.orWhere('note.fileIds != \'{}\'')
.orWhere('note.hasPoll = TRUE');
}));
})); }));
}));
})) }))
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')

View File

@ -15,6 +15,7 @@ import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -72,8 +73,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService, private cacheService: CacheService,
private redisTimelineService: RedisTimelineService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
const policies = await this.roleService.getUserPolicies(me.id); const policies = await this.roleService.getUserPolicies(me.id);
if (!policies.ltlAvailable) { if (!policies.ltlAvailable) {
throw new ApiError(meta.errors.stlDisabled); throw new ApiError(meta.errors.stlDisabled);
@ -89,29 +94,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.cacheService.userBlockedCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id),
]); ]);
let timeline: MiNote[] = []; const [htlNoteIds, ltlNoteIds] = await this.redisTimelineService.getMulti([
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
], untilId, sinceId);
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let htlNoteIdsRes: [string, string[]][] = [];
let ltlNoteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
[htlNoteIdsRes, ltlNoteIdsRes] = await Promise.all([
this.redisForTimelines.xrevrange(
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit),
this.redisForTimelines.xrevrange(
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit),
]);
}
const htlNoteIds = htlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
const ltlNoteIds = ltlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1); noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit); noteIds = noteIds.slice(0, ps.limit);
@ -129,7 +116,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
timeline = await query.getMany(); let timeline = await query.getMany();
timeline = timeline.filter(note => { timeline = timeline.filter(note => {
if (note.userId === me.id) { if (note.userId === me.id) {

View File

@ -15,6 +15,7 @@ import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -68,8 +69,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService, private cacheService: CacheService,
private redisTimelineService: RedisTimelineService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
const policies = await this.roleService.getUserPolicies(me ? me.id : null); const policies = await this.roleService.getUserPolicies(me ? me.id : null);
if (!policies.ltlAvailable) { if (!policies.ltlAvailable) {
throw new ApiError(meta.errors.ltlDisabled); throw new ApiError(meta.errors.ltlDisabled);
@ -85,20 +90,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.cacheService.userBlockedCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()]; ]) : [new Set<string>(), new Set<string>(), new Set<string>()];
let timeline: MiNote[] = []; let noteIds = await this.redisTimelineService.get(ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 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 ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
if (noteIds.length === 0) { if (noteIds.length === 0) {
return []; return [];
@ -113,7 +106,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
timeline = await query.getMany(); let timeline = await query.getMany();
timeline = timeline.filter(note => { timeline = timeline.filter(note => {
if (me && (note.userId === me.id)) { if (me && (note.userId === me.id)) {
@ -131,6 +124,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return true; return true;
}); });
// TODO: フィルタした結果件数が足りなかった場合の対応
timeline.sort((a, b) => a.id > b.id ? -1 : 1); timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => { process.nextTick(() => {

View File

@ -59,9 +59,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.where('following.followerId = :followerId', { followerId: me.id }); .where('following.followerId = :followerId', { followerId: me.id });
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where(`'{"${me.id}"}' <@ note.mentions`) qb
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`); .where(`'{"${me.id}"}' <@ note.mentions`)
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
})) }))
// Avoid scanning primary key index // Avoid scanning primary key index
.orderBy('CONCAT(note.id)', 'DESC') .orderBy('CONCAT(note.id)', 'DESC')

View File

@ -57,9 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.where('poll.userHost IS NULL') .where('poll.userHost IS NULL')
.andWhere('poll.userId != :meId', { meId: me.id }) .andWhere('poll.userId != :meId', { meId: me.id })
.andWhere('poll.noteVisibility = \'public\'') .andWhere('poll.noteVisibility = \'public\'')
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('poll.expiresAt IS NULL') qb
.orWhere('poll.expiresAt > :now', { now: new Date() }); .where('poll.expiresAt IS NULL')
.orWhere('poll.expiresAt > :now', { now: new Date() });
})); }));
//#region exclude arleady voted polls //#region exclude arleady voted polls

View File

@ -15,6 +15,7 @@ import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -62,8 +63,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private idService: IdService, private idService: IdService,
private cacheService: CacheService, private cacheService: CacheService,
private redisTimelineService: RedisTimelineService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
const [ const [
followings, followings,
userIdsWhoMeMuting, userIdsWhoMeMuting,
@ -76,20 +81,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.cacheService.userBlockedCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id),
]); ]);
let timeline: MiNote[] = []; let noteIds = await this.redisTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 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 ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
if (noteIds.length === 0) { if (noteIds.length === 0) {
return []; return [];
@ -104,7 +97,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
timeline = await query.getMany(); let timeline = await query.getMany();
timeline = timeline.filter(note => { timeline = timeline.filter(note => {
if (note.userId === me.id) { if (note.userId === me.id) {

View File

@ -15,6 +15,7 @@ import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -79,8 +80,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService, private cacheService: CacheService,
private idService: IdService, private idService: IdService,
private redisTimelineService: RedisTimelineService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
const list = await this.userListsRepository.findOneBy({ const list = await this.userListsRepository.findOneBy({
id: ps.listId, id: ps.listId,
userId: me.id, userId: me.id,
@ -100,20 +105,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.cacheService.userBlockedCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id),
]); ]);
let timeline: MiNote[] = []; let noteIds = await this.redisTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 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 ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
if (noteIds.length === 0) { if (noteIds.length === 0) {
return []; return [];
@ -128,7 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
timeline = await query.getMany(); let timeline = await query.getMany();
timeline = timeline.filter(note => { timeline = timeline.filter(note => {
if (note.userId === me.id) { if (note.userId === me.id) {

View File

@ -11,6 +11,7 @@ import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -65,8 +66,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService, private idService: IdService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private redisTimelineService: RedisTimelineService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
const role = await this.rolesRepository.findOneBy({ const role = await this.rolesRepository.findOneBy({
id: ps.roleId, id: ps.roleId,
isPublic: true, isPublic: true,
@ -78,18 +83,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!role.isExplorable) { if (!role.isExplorable) {
return []; return [];
} }
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
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 ?? '-',
'COUNT', limit);
if (noteIdsRes.length === 0) { let noteIds = await this.redisTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId);
return []; noteIds = noteIds.slice(0, ps.limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
if (noteIds.length === 0) { if (noteIds.length === 0) {
return []; return [];

View File

@ -62,9 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id }) .andWhere('assign.roleId = :roleId', { roleId: role.id })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('assign.expiresAt IS NULL') qb
.orWhere('assign.expiresAt > :now', { now: new Date() }); .where('assign.expiresAt IS NULL')
.orWhere('assign.expiresAt > :now', { now: new Date() });
})) }))
.innerJoinAndSelect('assign.user', 'user'); .innerJoinAndSelect('assign.user', 'user');

View File

@ -50,16 +50,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50); let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50);
if (noteIds.length === 0) {
return [];
}
noteIds.sort((a, b) => a > b ? -1 : 1); noteIds.sort((a, b) => a > b ? -1 : 1);
if (ps.untilId) { if (ps.untilId) {
noteIds = noteIds.filter(id => id < ps.untilId!); noteIds = noteIds.filter(id => id < ps.untilId!);
} }
noteIds = noteIds.slice(0, ps.limit); noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) {
return [];
}
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')

View File

@ -10,10 +10,11 @@ import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { QueryService } from '@/core/QueryService.js';
import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -67,90 +68,116 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private getterService: GetterService, private queryService: QueryService,
private cacheService: CacheService, private cacheService: CacheService,
private idService: IdService, private idService: IdService,
private redisTimelineService: RedisTimelineService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const [ const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
userIdsWhoMeMuting, const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
] = me ? await Promise.all([ const isRangeSpecified = untilId != null && sinceId != null;
this.cacheService.userMutingsCache.fetch(me.id), const isSelf = me && (me.id === ps.userId);
]) : [new Set<string>()];
let timeline: MiNote[] = []; if (isRangeSpecified || sinceId == null) {
const [
userIdsWhoMeMuting,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
let noteIdsRes: [string, string[]][] = []; this.redisTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId),
let repliesNoteIdsRes: [string, string[]][] = []; ps.withReplies ? this.redisTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
let channelNoteIdsRes: [string, string[]][] = []; ps.withChannelNotes ? this.redisTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
if (!ps.sinceId && !ps.sinceDate) {
[noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
this.redisForTimelines.xrevrange(
ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit),
ps.withReplies
? this.redisForTimelines.xrevrange(
`userTimelineWithReplies:${ps.userId}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit)
: Promise.resolve([]),
ps.withChannelNotes
? this.redisForTimelines.xrevrange(
`userTimelineWithChannel:${ps.userId}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit)
: Promise.resolve([]),
]); ]);
let noteIds = Array.from(new Set([
...noteIdsRes,
...repliesNoteIdsRes,
...channelNoteIdsRes,
]));
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length > 0) {
const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId);
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('note.channel', 'channel');
let timeline = await query.getMany();
timeline = timeline.filter(note => {
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (ps.withRenotes === false) return false;
}
}
if (note.channel?.isSensitive && !isSelf) return false;
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
return true;
});
// TODO: フィルタで件数が減った場合の埋め合わせ処理
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
if (timeline.length > 0) {
return await this.noteEntityService.packMany(timeline, me);
}
}
} }
let noteIds = Array.from(new Set([ //#region fallback to database
...noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId), const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
...repliesNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId), .andWhere('note.userId = :userId', { userId: ps.userId })
...channelNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
]));
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
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') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('note.channel', 'channel')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('note.channel', 'channel');
timeline = await query.getMany(); if (!ps.withChannelNotes) {
query.andWhere('note.channelId IS NULL');
}
timeline = timeline.filter(note => { this.queryService.generateVisibilityQuery(query, me);
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false; if (me) {
this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
this.queryService.generateBlockedUserQuery(query, me);
}
if (note.renoteId) { if (ps.withFiles) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { query.andWhere('note.fileIds != \'{}\'');
if (ps.withRenotes === false) return false; }
}
}
if (note.visibility === 'followers' && !isFollowing) return false; if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :userId', { userId: ps.userId });
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; const timeline = await query.limit(ps.limit).getMany();
});
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
//#endregion
}); });
} }
} }

View File

@ -92,9 +92,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere(`user.id IN (${ followingQuery.getQuery() })`) .andWhere(`user.id IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id }) .andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE') .andWhere('user.isSuspended = FALSE')
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('user.updatedAt IS NULL') qb
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})); }));
query.setParameters(followingQuery.getParameters()); query.setParameters(followingQuery.getParameters());

View File

@ -64,9 +64,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (isUsername) { if (isUsername) {
const usernameQuery = this.usersRepository.createQueryBuilder('user') const usernameQuery = this.usersRepository.createQueryBuilder('user')
.where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' }) .where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' })
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('user.updatedAt IS NULL') qb
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})) }))
.andWhere('user.isSuspended = FALSE'); .andWhere('user.isSuspended = FALSE');
@ -91,9 +92,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' }); qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' });
} }
})) }))
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('user.updatedAt IS NULL') qb
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})) }))
.andWhere('user.isSuspended = FALSE'); .andWhere('user.isSuspended = FALSE');
@ -122,9 +124,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.usersRepository.createQueryBuilder('user') const query = this.usersRepository.createQueryBuilder('user')
.where(`user.id IN (${ profQuery.getQuery() })`) .where(`user.id IN (${ profQuery.getQuery() })`)
.andWhere(new Brackets(qb => { qb .andWhere(new Brackets(qb => {
.where('user.updatedAt IS NULL') qb
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); .where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
})) }))
.andWhere('user.isSuspended = FALSE') .andWhere('user.isSuspended = FALSE')
.setParameters(profQuery.getParameters()); .setParameters(profQuery.getParameters());

View File

@ -16,9 +16,10 @@ import Channel from '../channel.js';
class GlobalTimelineChannel extends Channel { class GlobalTimelineChannel extends Channel {
public readonly chName = 'globalTimeline'; public readonly chName = 'globalTimeline';
public static shouldShare = true; public static shouldShare = false;
public static requireCredential = false; public static requireCredential = false;
private withRenotes: boolean; private withRenotes: boolean;
private withFiles: boolean;
constructor( constructor(
private metaService: MetaService, private metaService: MetaService,
@ -38,6 +39,7 @@ class GlobalTimelineChannel extends Channel {
if (!policies.gtlAvailable) return; if (!policies.gtlAvailable) return;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
this.withFiles = params.withFiles ?? false;
// Subscribe events // Subscribe events
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
@ -45,6 +47,8 @@ class GlobalTimelineChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (note.visibility !== 'public') return; if (note.visibility !== 'public') return;
if (note.channelId != null) return; if (note.channelId != null) return;

View File

@ -14,9 +14,10 @@ import Channel from '../channel.js';
class HomeTimelineChannel extends Channel { class HomeTimelineChannel extends Channel {
public readonly chName = 'homeTimeline'; public readonly chName = 'homeTimeline';
public static shouldShare = true; public static shouldShare = false;
public static requireCredential = true; public static requireCredential = true;
private withRenotes: boolean; private withRenotes: boolean;
private withFiles: boolean;
constructor( constructor(
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
@ -31,12 +32,15 @@ class HomeTimelineChannel extends Channel {
@bindThis @bindThis
public async init(params: any) { public async init(params: any) {
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
this.withFiles = params.withFiles ?? false;
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
} }
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (note.channelId) { if (note.channelId) {
if (!this.followingChannels.has(note.channelId)) return; if (!this.followingChannels.has(note.channelId)) return;
} else { } else {

View File

@ -16,9 +16,10 @@ import Channel from '../channel.js';
class HybridTimelineChannel extends Channel { class HybridTimelineChannel extends Channel {
public readonly chName = 'hybridTimeline'; public readonly chName = 'hybridTimeline';
public static shouldShare = true; public static shouldShare = false;
public static requireCredential = true; public static requireCredential = true;
private withRenotes: boolean; private withRenotes: boolean;
private withFiles: boolean;
constructor( constructor(
private metaService: MetaService, private metaService: MetaService,
@ -38,6 +39,7 @@ class HybridTimelineChannel extends Channel {
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
this.withFiles = params.withFiles ?? false;
// Subscribe events // Subscribe events
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
@ -45,6 +47,8 @@ class HybridTimelineChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
// チャンネルの投稿ではなく、自分自身の投稿 または // チャンネルの投稿ではなく、自分自身の投稿 または
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または // チャンネルの投稿ではなく、全体公開のローカルの投稿 または

View File

@ -15,9 +15,10 @@ import Channel from '../channel.js';
class LocalTimelineChannel extends Channel { class LocalTimelineChannel extends Channel {
public readonly chName = 'localTimeline'; public readonly chName = 'localTimeline';
public static shouldShare = true; public static shouldShare = false;
public static requireCredential = false; public static requireCredential = false;
private withRenotes: boolean; private withRenotes: boolean;
private withFiles: boolean;
constructor( constructor(
private metaService: MetaService, private metaService: MetaService,
@ -37,6 +38,7 @@ class LocalTimelineChannel extends Channel {
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
this.withFiles = params.withFiles ?? false;
// Subscribe events // Subscribe events
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
@ -44,6 +46,8 @@ class LocalTimelineChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (note.user.host !== null) return; if (note.user.host !== null) return;
if (note.visibility !== 'public') return; if (note.visibility !== 'public') return;
if (note.channelId != null && !this.followingChannels.has(note.channelId)) return; if (note.channelId != null && !this.followingChannels.has(note.channelId)) return;

View File

@ -18,8 +18,9 @@ class UserListChannel extends Channel {
public static shouldShare = false; public static shouldShare = false;
public static requireCredential = false; public static requireCredential = false;
private listId: string; private listId: string;
public membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {}; private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
private listUsersClock: NodeJS.Timeout; private listUsersClock: NodeJS.Timeout;
private withFiles: boolean;
constructor( constructor(
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@ -37,6 +38,7 @@ class UserListChannel extends Channel {
@bindThis @bindThis
public async init(params: any) { public async init(params: any) {
this.listId = params.listId as string; this.listId = params.listId as string;
this.withFiles = params.withFiles ?? false;
// Check existence and owner // Check existence and owner
const listExist = await this.userListsRepository.exist({ const listExist = await this.userListsRepository.exist({
@ -76,6 +78,8 @@ class UserListChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!Object.hasOwn(this.membershipsMap, note.userId)) return; if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
if (['followers', 'specified'].includes(note.visibility)) { if (['followers', 'specified'].includes(note.visibility)) {

View File

@ -3,6 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
// How to run:
// pnpm jest -- e2e/timelines.ts
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true'; process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true';
@ -378,6 +381,104 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
}); });
test.concurrent('自分の visibility: specified なノートが含まれる', async () => {
const [alice] = await Promise.all([signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' });
await waitForPushToTl();
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('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
await waitForPushToTl();
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');
});
test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
const aliceNote = await post(alice, { text: 'ok', visibility: 'specified', visibleUserIds: [bob.id], replyId: bobNote.id });
await waitForPushToTl();
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, 'ok');
});
/* TODO
test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] });
const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id });
await waitForPushToTl();
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, 'ok');
});
*/
// ↑の挙動が理想だけど実装が面倒かも
test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] });
const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
}); });
describe('Local TL', () => { describe('Local TL', () => {
@ -630,7 +731,6 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
}); });
/*
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);
@ -645,23 +745,6 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); 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);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await waitForPushToTl();
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 () => { test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
@ -778,6 +861,38 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); 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 === bobNote2.id), true);
}, 1000 * 10); }, 1000 * 10);
test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', 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);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
await waitForPushToTl();
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('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', 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/push', { listId: list.id, userId: carol.id }, alice);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] });
await waitForPushToTl();
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
}); });
describe('User TL', () => { describe('User TL', () => {
@ -820,6 +935,19 @@ describe('Timelines', () => {
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
}); });
test.concurrent('自身の visibility: followers なノートが含まれる', async () => {
const [alice] = await Promise.all([signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' });
await waitForPushToTl();
const res = await api('/users/notes', { userId: alice.id }, 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 () => { test.concurrent('チャンネル投稿が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);
@ -938,6 +1066,30 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.id), true); assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.id), true);
}); });
test.concurrent('自身の visibility: specified なノートが含まれる', async () => {
const [alice] = await Promise.all([signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' });
await waitForPushToTl();
const res = await api('/users/notes', { userId: alice.id, withReplies: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
});
test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified' });
await waitForPushToTl();
const res = await api('/users/notes', { userId: bob.id, withReplies: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
}); });
// TODO: リノートミュート済みユーザーのテスト // TODO: リノートミュート済みユーザーのテスト

View File

@ -0,0 +1,88 @@
import { DebounceLoader } from '@/misc/loader.js';
class Mock {
loadCountByKey = new Map<number, number>();
load = async (key: number): Promise<number> => {
const count = this.loadCountByKey.get(key);
if (typeof count === 'undefined') {
this.loadCountByKey.set(key, 1);
} else {
this.loadCountByKey.set(key, count + 1);
}
return key * 2;
};
reset() {
this.loadCountByKey.clear();
}
}
describe(DebounceLoader, () => {
describe('single request', () => {
it('loads once', async () => {
const mock = new Mock();
const loader = new DebounceLoader(mock.load);
expect(await loader.load(7)).toBe(14);
expect(mock.loadCountByKey.size).toBe(1);
expect(mock.loadCountByKey.get(7)).toBe(1);
});
});
describe('two duplicated requests at same time', () => {
it('loads once', async () => {
const mock = new Mock();
const loader = new DebounceLoader(mock.load);
const [v1, v2] = await Promise.all([
loader.load(7),
loader.load(7),
]);
expect(v1).toBe(14);
expect(v2).toBe(14);
expect(mock.loadCountByKey.size).toBe(1);
expect(mock.loadCountByKey.get(7)).toBe(1);
});
});
describe('two different requests at same time', () => {
it('loads twice', async () => {
const mock = new Mock();
const loader = new DebounceLoader(mock.load);
const [v1, v2] = await Promise.all([
loader.load(7),
loader.load(13),
]);
expect(v1).toBe(14);
expect(v2).toBe(26);
expect(mock.loadCountByKey.size).toBe(2);
expect(mock.loadCountByKey.get(7)).toBe(1);
expect(mock.loadCountByKey.get(13)).toBe(1);
});
});
describe('non-continuous same two requests', () => {
it('loads twice', async () => {
const mock = new Mock();
const loader = new DebounceLoader(mock.load);
expect(await loader.load(7)).toBe(14);
expect(mock.loadCountByKey.size).toBe(1);
expect(mock.loadCountByKey.get(7)).toBe(1);
mock.reset();
expect(await loader.load(7)).toBe(14);
expect(mock.loadCountByKey.size).toBe(1);
expect(mock.loadCountByKey.get(7)).toBe(1);
});
});
describe('non-continuous different two requests', () => {
it('loads twice', async () => {
const mock = new Mock();
const loader = new DebounceLoader(mock.load);
expect(await loader.load(7)).toBe(14);
expect(mock.loadCountByKey.size).toBe(1);
expect(mock.loadCountByKey.get(7)).toBe(1);
mock.reset();
expect(await loader.load(13)).toBe(26);
expect(mock.loadCountByKey.size).toBe(1);
expect(mock.loadCountByKey.get(13)).toBe(1);
});
});
});

View File

@ -457,6 +457,7 @@ export async function testPaginationConsistency<Entity extends { id: string, cre
}; };
for (const limit of [1, 5, 10, 100, undefined]) { for (const limit of [1, 5, 10, 100, undefined]) {
/*
// 1. sinceId/DateとuntilId/Dateで両端を指定して取得した結果が期待通りになっていること // 1. sinceId/DateとuntilId/Dateで両端を指定して取得した結果が期待通りになっていること
if (ordering === 'desc') { if (ordering === 'desc') {
const end = expected.at(-1)!; const end = expected.at(-1)!;
@ -485,6 +486,7 @@ export async function testPaginationConsistency<Entity extends { id: string, cre
actual.map(({ id, createdAt }) => id + ':' + createdAt), actual.map(({ id, createdAt }) => id + ':' + createdAt),
expected.map(({ id, createdAt }) => id + ':' + createdAt)); expected.map(({ id, createdAt }) => id + ':' + createdAt));
} }
*/
// 3. untilId指定+limitで取得してつなぎ合わせた結果が期待通りになっていること // 3. untilId指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
if (ordering === 'desc') { if (ordering === 'desc') {

View File

@ -38,7 +38,7 @@
"chartjs-chart-matrix": "2.0.1", "chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"chromatic": "7.2.2", "chromatic": "7.2.3",
"compare-versions": "6.1.0", "compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4", "cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
@ -57,9 +57,9 @@
"prismjs": "1.29.0", "prismjs": "1.29.0",
"punycode": "2.3.0", "punycode": "2.3.0",
"querystring": "0.2.1", "querystring": "0.2.1",
"rollup": "4.0.0", "rollup": "4.0.2",
"sanitize-html": "2.11.0", "sanitize-html": "2.11.0",
"sass": "1.69.0", "sass": "1.69.1",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"three": "0.157.0", "three": "0.157.0",
@ -101,22 +101,22 @@
"@types/estree": "1.0.2", "@types/estree": "1.0.2",
"@types/matter-js": "0.19.1", "@types/matter-js": "0.19.1",
"@types/micromatch": "4.0.3", "@types/micromatch": "4.0.3",
"@types/node": "20.8.2", "@types/node": "20.8.4",
"@types/punycode": "2.1.0", "@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.1", "@types/sanitize-html": "2.9.1",
"@types/throttle-debounce": "5.0.0", "@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.4", "@types/tinycolor2": "1.4.4",
"@types/uuid": "9.0.4", "@types/uuid": "9.0.5",
"@types/websocket": "1.0.7", "@types/websocket": "1.0.7",
"@types/ws": "8.5.6", "@types/ws": "8.5.6",
"@typescript-eslint/eslint-plugin": "6.7.4", "@typescript-eslint/eslint-plugin": "6.7.5",
"@typescript-eslint/parser": "6.7.4", "@typescript-eslint/parser": "6.7.5",
"@vitest/coverage-v8": "0.34.6", "@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.4", "@vue/runtime-core": "3.3.4",
"acorn": "8.10.0", "acorn": "8.10.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.3.0", "cypress": "13.3.0",
"eslint": "8.50.0", "eslint": "8.51.0",
"eslint-plugin-import": "2.28.1", "eslint-plugin-import": "2.28.1",
"eslint-plugin-vue": "9.17.0", "eslint-plugin-vue": "9.17.0",
"fast-glob": "3.3.1", "fast-glob": "3.3.1",
@ -135,7 +135,7 @@
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6", "vitest": "0.34.6",
"vitest-fetch-mock": "0.2.2", "vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.3.1", "vue-eslint-parser": "9.3.2",
"vue-tsc": "1.8.15" "vue-tsc": "1.8.18"
} }
} }

View File

@ -4,10 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<button class="_button" :class="$style.root" @mousedown="toggle"> <MkButton rounded full small @click="toggle"><b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b><span v-if="!modelValue" :class="$style.label">{{ label }}</span></MkButton>
<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
<span v-if="!modelValue" :class="$style.label">{{ label }}</span>
</button>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -15,6 +12,7 @@ import { computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { concat } from '@/scripts/array.js'; import { concat } from '@/scripts/array.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
@ -33,25 +31,12 @@ const label = computed(() => {
] as string[][]).join(' / '); ] as string[][]).join(' / ');
}); });
const toggle = () => { function toggle() {
emit('update:modelValue', !props.modelValue); emit('update:modelValue', !props.modelValue);
}; }
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.root {
display: inline-block;
padding: 4px 8px;
font-size: 0.7em;
color: var(--cwFg);
background: var(--cwBg);
border-radius: 2px;
&:hover {
background: var(--cwHoverBg);
}
}
.label { .label {
margin-left: 4px; margin-left: 4px;

View File

@ -45,8 +45,11 @@ import bytes from '@/filters/bytes.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { useRouter } from '@/router.js';
import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js';
const router = useRouter();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
file: Misskey.entities.DriveFile; file: Misskey.entities.DriveFile;
folder: Misskey.entities.DriveFolder | null; folder: Misskey.entities.DriveFolder | null;
@ -71,7 +74,7 @@ function onClick(ev: MouseEvent) {
if (props.selectMode) { if (props.selectMode) {
emit('chosen', props.file); emit('chosen', props.file);
} else { } else {
os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); router.push(`/my/drive/file/${props.file.id}`);
} }
} }

View File

@ -23,6 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:spellcheck="spellcheck" :spellcheck="spellcheck"
:step="step" :step="step"
:list="id" :list="id"
:min="min"
:max="max"
@focus="focused = true" @focus="focused = true"
@blur="focused = false" @blur="focused = false"
@keydown="onKeydown($event)" @keydown="onKeydown($event)"
@ -59,6 +61,8 @@ const props = defineProps<{
spellcheck?: boolean; spellcheck?: boolean;
step?: any; step?: any;
datalist?: string[]; datalist?: string[];
min?: number;
max?: number;
inline?: boolean; inline?: boolean;
debounce?: boolean; debounce?: boolean;
manualSave?: boolean; manualSave?: boolean;

View File

@ -17,7 +17,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:title="media.name" :title="media.name"
controls controls
preload="metadata" preload="metadata"
@volumechange="volumechange"
/> />
</div> </div>
<a <a
@ -33,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { soundConfigStore } from '@/scripts/sound.js'; import { soundConfigStore } from '@/scripts/sound.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -43,15 +42,13 @@ const props = withDefaults(defineProps<{
}>(), { }>(), {
}); });
const audioEl = $shallowRef<HTMLAudioElement | null>(); const audioEl = shallowRef<HTMLAudioElement>();
let hide = $ref(true); let hide = $ref(true);
function volumechange() { watch(audioEl, () => {
if (audioEl) soundConfigStore.set('mediaVolume', audioEl.volume); if (audioEl.value) {
} audioEl.value.volume = 0.3;
}
onMounted(() => {
if (audioEl) audioEl.volume = soundConfigStore.state.mediaVolume;
}); });
</script> </script>

View File

@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-else :class="[$style.visible, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]"> <div v-else :class="[$style.visible, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]">
<video <video
ref="videoEl"
:class="$style.video" :class="$style.video"
:poster="video.thumbnailUrl" :poster="video.thumbnailUrl"
:title="video.comment" :title="video.comment"
@ -31,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import bytes from '@/filters/bytes.js'; import bytes from '@/filters/bytes.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
@ -42,6 +43,14 @@ const props = defineProps<{
}>(); }>();
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
const videoEl = shallowRef<HTMLVideoElement>();
watch(videoEl, () => {
if (videoEl.value) {
videoEl.value.volume = 0.3;
}
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="container-type: inline-size;"> <div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw"> <p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
<MkCwButton v-model="showContent" :note="appearNote"/> <MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;"/>
</p> </p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text"> <div :class="$style.text">

View File

@ -13,6 +13,7 @@ import MkNotes from '@/components/MkNotes.vue';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { instance } from '@/instance.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@ -38,7 +39,15 @@ provide('inChannel', computed(() => props.src === 'channel'));
const tlComponent: InstanceType<typeof MkNotes> = $ref(); const tlComponent: InstanceType<typeof MkNotes> = $ref();
let tlNotesCount = 0;
const prepend = note => { const prepend = note => {
tlNotesCount++;
if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) {
note._shouldInsertAd_ = true;
}
tlComponent.pagingComponent?.prepend(note); tlComponent.pagingComponent?.prepend(note);
emit('note'); emit('note');
@ -129,12 +138,10 @@ if (props.src === 'antenna') {
} else if (props.src === 'list') { } else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline'; endpoint = 'notes/user-list-timeline';
query = { query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
listId: props.list, listId: props.list,
}; };
connection = stream.useChannel('userList', { connection = stream.useChannel('userList', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
listId: props.list, listId: props.list,
}); });

View File

@ -187,6 +187,9 @@ const patronsWithIcon = [{
}, { }, {
name: 'フランギ・シュウ', name: 'フランギ・シュウ',
icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg', icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg',
}, {
name: '百日紅',
icon: 'https://misskey-hub.net/patrons/302dce2898dd457ba03c3f7dc037900b.jpg',
}]; }];
const patrons = [ const patrons = [

View File

@ -107,6 +107,22 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput> </MkInput>
</div> </div>
</FormSection> </FormSection>
<FormSection>
<template #label>{{ i18n.ts._ad.adsSettings }}</template>
<div class="_gaps_m">
<div class="_gaps_s">
<MkInput v-model="notesPerOneAd" :min="0" type="number">
<template #label>{{ i18n.ts._ad.notesPerOneAd }}</template>
<template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template>
</MkInput>
<MkInfo v-if="notesPerOneAd > 0 && notesPerOneAd < 20" :warn="true">
{{ i18n.ts._ad.adsTooClose }}
</MkInfo>
</div>
</div>
</FormSection>
</div> </div>
</FormSuspense> </FormSuspense>
</MkSpacer> </MkSpacer>
@ -127,6 +143,7 @@ import XHeader from './_header_.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue'; import FormSplit from '@/components/form/split.vue';
import FormSuspense from '@/components/form/suspense.vue'; import FormSuspense from '@/components/form/suspense.vue';
@ -152,6 +169,7 @@ let perLocalUserUserTimelineCacheMax: number = $ref(0);
let perRemoteUserUserTimelineCacheMax: number = $ref(0); let perRemoteUserUserTimelineCacheMax: number = $ref(0);
let perUserHomeTimelineCacheMax: number = $ref(0); let perUserHomeTimelineCacheMax: number = $ref(0);
let perUserListTimelineCacheMax: number = $ref(0); let perUserListTimelineCacheMax: number = $ref(0);
let notesPerOneAd: number = $ref(0);
async function init(): Promise<void> { async function init(): Promise<void> {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
@ -171,10 +189,11 @@ async function init(): Promise<void> {
perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax; perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax;
perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax; perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax;
perUserListTimelineCacheMax = meta.perUserListTimelineCacheMax; perUserListTimelineCacheMax = meta.perUserListTimelineCacheMax;
notesPerOneAd = meta.notesPerOneAd;
} }
function save(): void { async function save(): void {
os.apiWithDialog('admin/update-meta', { await os.apiWithDialog('admin/update-meta', {
name, name,
shortName: shortName === '' ? null : shortName, shortName: shortName === '' ? null : shortName,
description, description,
@ -191,9 +210,10 @@ function save(): void {
perRemoteUserUserTimelineCacheMax, perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax, perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax, perUserListTimelineCacheMax,
}).then(() => { notesPerOneAd,
fetchInstance();
}); });
fetchInstance();
} }
const headerTabs = $computed(() => []); const headerTabs = $computed(() => []);

View File

@ -0,0 +1,302 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<MkInfo>{{ i18n.ts._fileViewer.thisPageCanBeSeenFromTheAuthor }}</MkInfo>
<MkLoading v-if="fetching"/>
<div v-else-if="file" class="_gaps">
<div :class="$style.filePreviewRoot">
<MkMediaList :mediaList="[file]"></MkMediaList>
</div>
<div :class="$style.fileQuickActionsRoot">
<button class="_button" :class="$style.fileNameEditBtn" @click="rename()">
<h2 class="_nowrap" :class="$style.fileName">{{ file.name }}</h2>
<i class="ti ti-pencil" :class="$style.fileNameEditIcon"></i>
</button>
<div :class="$style.fileQuickActionsOthers">
<button v-tooltip="i18n.ts.createNoteFromTheFile" class="_button" :class="$style.fileQuickActionsOthersButton" @click="postThis()">
<i class="ti ti-pencil"></i>
</button>
<button v-if="isImage" v-tooltip="i18n.ts.cropImage" class="_button" :class="$style.fileQuickActionsOthersButton" @click="crop()">
<i class="ti ti-crop"></i>
</button>
<button v-if="file.isSensitive" v-tooltip="i18n.ts.unmarkAsSensitive" class="_button" :class="$style.fileQuickActionsOthersButton" @click="toggleSensitive()">
<i class="ti ti-eye"></i>
</button>
<button v-else v-tooltip="i18n.ts.markAsSensitive" class="_button" :class="$style.fileQuickActionsOthersButton" @click="toggleSensitive()">
<i class="ti ti-eye-exclamation"></i>
</button>
<a v-tooltip="i18n.ts.download" :href="file.url" :download="file.name" class="_button" :class="$style.fileQuickActionsOthersButton">
<i class="ti ti-download"></i>
</a>
<button v-tooltip="i18n.ts.delete" class="_button" :class="[$style.fileQuickActionsOthersButton, $style.danger]" @click="deleteFile()">
<i class="ti ti-trash"></i>
</button>
</div>
</div>
<div>
<button class="_button" :class="$style.fileAltEditBtn" @click="describe()">
<MkKeyValue>
<template #key>{{ i18n.ts.description }}</template>
<template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ti ti-pencil" :class="$style.fileAltEditIcon"></i></template>
</MkKeyValue>
</button>
<MkKeyValue :class="$style.fileMetaDataChildren">
<template #key>{{ i18n.ts._fileViewer.uploadedAt }}</template>
<template #value><MkTime :time="file.createdAt" mode="detail"/></template>
</MkKeyValue>
<MkKeyValue :class="$style.fileMetaDataChildren">
<template #key>{{ i18n.ts._fileViewer.type }}</template>
<template #value>{{ file.type }}</template>
</MkKeyValue>
<MkKeyValue :class="$style.fileMetaDataChildren">
<template #key>{{ i18n.ts._fileViewer.size }}</template>
<template #value>{{ bytes(file.size) }}</template>
</MkKeyValue>
</div>
</div>
<div v-else class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineAsyncComponent, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import MkInfo from '@/components/MkInfo.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import bytes from '@/filters/bytes.js';
import { infoImageUrl } from '@/instance.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { useRouter } from '@/router.js';
const router = useRouter();
const props = defineProps<{
fileId: string;
}>();
const fetching = ref(true);
const file = ref<Misskey.entities.DriveFile>();
const isImage = computed(() => file.value?.type.startsWith('image/'));
async function fetch() {
fetching.value = true;
file.value = await os.api('drive/files/show', {
fileId: props.fileId,
}).catch((err) => {
console.error(err);
return undefined;
});
fetching.value = false;
}
function postThis() {
if (!file.value) return;
os.post({
initialFiles: [file.value],
});
}
function crop() {
if (!file.value) return;
os.cropImage(file.value, {
aspectRatio: NaN,
uploadFolder: file.value.folderId ?? null,
});
}
function toggleSensitive() {
if (!file.value) return;
os.apiWithDialog('drive/files/update', {
fileId: file.value.id,
isSensitive: !file.value.isSensitive,
}).then(async () => {
await fetch();
}).catch(err => {
os.alert({
type: 'error',
title: i18n.ts.error,
text: err.message,
});
});
}
function rename() {
if (!file.value) return;
os.inputText({
title: i18n.ts.renameFile,
placeholder: i18n.ts.inputNewFileName,
default: file.value.name,
}).then(({ canceled, result: name }) => {
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: file.value.id,
name: name,
}).then(async () => {
await fetch();
});
});
}
function describe() {
if (!file.value) return;
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.value.comment ?? '',
file: file.value,
}, {
done: caption => {
os.apiWithDialog('drive/files/update', {
fileId: file.value.id,
comment: caption.length === 0 ? null : caption,
}).then(async () => {
await fetch();
});
},
}, 'closed');
}
async function deleteFile() {
if (!file.value) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('driveFileDeleteConfirm', { name: file.value.name }),
});
if (canceled) return;
await os.apiWithDialog('drive/files/delete', {
fileId: file.value.id,
});
router.push('/my/drive');
}
onMounted(async () => {
await fetch();
});
</script>
<style lang="scss" module>
.filePreviewRoot {
background: var(--panel);
border-radius: var(--radius);
// MkMediaList 4px
padding: calc(1rem - 4px) 1rem 1rem;
}
.fileQuickActionsRoot {
display: flex;
flex-direction: column;
gap: 8px;
}
@container (min-width: 500px) {
.fileQuickActionsRoot {
flex-direction: row;
align-items: center;
}
}
.fileQuickActionsOthers {
margin-left: auto;
margin-right: 1rem;
display: flex;
gap: 8px;
.fileQuickActionsOthersButton {
padding: .5rem;
border-radius: 99rem;
&:hover,
&:focus-visible {
background-color: var(--accentedBg);
color: var(--accent);
text-decoration: none;
}
&.danger {
color: #ff2a2a;
}
&.danger:hover,
&.danger:focus-visible {
background-color: rgba(255, 42, 42, .15);
}
}
}
.fileNameEditBtn {
padding: .5rem 1rem;
display: flex;
align-items: center;
min-width: 0;
font-weight: 700;
border-radius: var(--radius);
font-size: .8rem;
>.fileNameEditIcon {
color: transparent;
visibility: hidden;
padding-left: .5rem;
}
>.fileName {
margin: 0;
}
&:hover {
background-color: var(--accentedBg);
>.fileName,
>.fileNameEditIcon {
visibility: visible;
color: var(--accent);
}
}
}
.fileMetaDataChildren {
padding: .5rem 1rem;
}
.fileAltEditBtn {
text-align: start;
display: block;
width: 100%;
padding: .5rem 1rem;
border-radius: var(--radius);
.fileAltEditIcon {
display: inline-block;
color: transparent;
visibility: hidden;
padding-left: .5rem;
}
&:hover {
color: var(--accent);
background-color: var(--accentedBg);
.fileAltEditIcon {
color: var(--accent);
visibility: visible;
}
}
}
</style>

View File

@ -0,0 +1,33 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<MkInfo>{{ i18n.ts._fileViewer.thisPageCanBeSeenFromTheAuthor }}</MkInfo>
<MkNotes ref="tlComponent" :pagination="pagination"/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { i18n } from '@/i18n.js';
import { Paging } from '@/components/MkPagination.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkNotes from '@/components/MkNotes.vue';
const props = defineProps<{
fileId: string;
}>();
const realFileId = computed(() => props.fileId);
const pagination = ref<Paging>({
endpoint: 'drive/files/attached-notes',
limit: 10,
params: {
fileId: realFileId.value,
},
});
</script>

View File

@ -0,0 +1,52 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header>
<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkSpacer v-if="tab === 'info'" :contentMax="800">
<XFileInfo :fileId="fileId"/>
</MkSpacer>
<MkSpacer v-else-if="tab === 'notes'" :contentMax="800">
<XNotes :fileId="fileId"/>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, ref, defineAsyncComponent } from 'vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const props = defineProps<{
fileId: string;
}>();
const XFileInfo = defineAsyncComponent(() => import('./drive.file.info.vue'));
const XNotes = defineAsyncComponent(() => import('./drive.file.notes.vue'));
const tab = ref('info');
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
key: 'info',
title: i18n.ts.info,
icon: 'ti ti-info-circle',
}, {
key: 'notes',
title: i18n.ts._fileViewer.attachedNotes,
icon: 'ti ti-pencil',
}]);
definePageMetadata(computed(() => ({
title: i18n.ts._fileViewer.title,
icon: 'ti ti-file',
})));
</script>

View File

@ -286,8 +286,7 @@ definePageMetadata(computed(() => {
let title = i18n.ts._pages.newPage; let title = i18n.ts._pages.newPage;
if (props.initPageId) { if (props.initPageId) {
title = i18n.ts._pages.editPage; title = i18n.ts._pages.editPage;
} } else if (props.initPageName && props.initUser) {
else if (props.initPageName && props.initUser) {
title = i18n.ts._pages.readPage; title = i18n.ts._pages.readPage;
} }
return { return {

View File

@ -61,20 +61,7 @@ function settings() {
router.push(`/my/lists/${props.listId}`); router.push(`/my/lists/${props.listId}`);
} }
async function timetravel() {
const { canceled, result: date } = await os.inputDate({
title: i18n.ts.date,
});
if (canceled) return;
tlEl.timetravel(date);
}
const headerActions = $computed(() => list ? [{ const headerActions = $computed(() => list ? [{
icon: 'ti ti-calendar-time',
text: i18n.ts.jumpToSpecifiedDate,
handler: timetravel,
}, {
icon: 'ti ti-settings', icon: 'ti ti-settings',
text: i18n.ts.settings, text: i18n.ts.settings,
handler: settings, handler: settings,

View File

@ -10,15 +10,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root"> <div :class="$style.root">
<MkLoading v-if="fetching"/> <MkLoading v-if="fetching"/>
<div v-if="!fetching && files.length > 0" :class="$style.stream"> <div v-if="!fetching && files.length > 0" :class="$style.stream">
<MkA <template v-for="file in files" :key="file.note.id + file.file.id">
v-for="file in files" <div v-if="file.file.isSensitive && !showingFiles.includes(file.file.id)" :class="$style.sensitive" @click="showingFiles.push(file.file.id)">
:key="file.note.id + file.file.id" <div>
:class="$style.img" <div><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</div>
:to="notePage(file.note)" <div>{{ i18n.ts.clickToShow }}</div>
> </div>
<!-- TODO: 画像以外のファイルに対応 --> </div>
<ImgWithBlurhash :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name"/> <MkA v-else :class="$style.img" :to="notePage(file.note)">
</MkA> <!-- TODO: 画像以外のファイルに対応 -->
<ImgWithBlurhash :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name"/>
</MkA>
</template>
</div> </div>
<p v-if="!fetching && files.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p> <p v-if="!fetching && files.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
</div> </div>
@ -45,6 +48,7 @@ let files = $ref<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
file: Misskey.entities.DriveFile; file: Misskey.entities.DriveFile;
}[]>([]); }[]>([]);
let showingFiles = $ref<string[]>([]);
function thumbnail(image: Misskey.entities.DriveFile): string { function thumbnail(image: Misskey.entities.DriveFile): string {
return defaultStore.state.disableShowingAnimatedImages return defaultStore.state.disableShowingAnimatedImages
@ -94,4 +98,9 @@ onMounted(() => {
padding: 16px; padding: 16px;
text-align: center; text-align: center;
} }
.sensitive {
display: grid;
place-items: center;
}
</style> </style>

View File

@ -467,6 +467,10 @@ export const routes = [{
path: '/my/drive', path: '/my/drive',
component: page(() => import('./pages/drive.vue')), component: page(() => import('./pages/drive.vue')),
loginRequired: true, loginRequired: true,
}, {
path: '/my/drive/file/:fileId',
component: page(() => import('./pages/drive.file.vue')),
loginRequired: true,
}, { }, {
path: '/my/follow-requests', path: '/my/follow-requests',
component: page(() => import('./pages/follow-requests.vue')), component: page(() => import('./pages/follow-requests.vue')),

View File

@ -27,7 +27,7 @@ function rename(file: Misskey.entities.DriveFile) {
function describe(file: Misskey.entities.DriveFile) { function describe(file: Misskey.entities.DriveFile) {
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.comment != null ? file.comment : '', default: file.comment ?? '',
file: file, file: file,
}, { }, {
done: caption => { done: caption => {
@ -112,6 +112,11 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
text: i18n.ts.download, text: i18n.ts.download,
icon: 'ti ti-download', icon: 'ti ti-download',
download: file.name, download: file.name,
}, null, {
type: 'link',
to: `/my/drive/file/${file.id}`,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-file',
}, null, { }, null, {
text: i18n.ts.delete, text: i18n.ts.delete,
icon: 'ti ti-trash', icon: 'ti ti-trash',

View File

@ -7,10 +7,6 @@ import { markRaw } from 'vue';
import { Storage } from '@/pizzax.js'; import { Storage } from '@/pizzax.js';
export const soundConfigStore = markRaw(new Storage('sound', { export const soundConfigStore = markRaw(new Storage('sound', {
mediaVolume: {
where: 'device',
default: 0.5,
},
sound_masterVolume: { sound_masterVolume: {
where: 'device', where: 'device',
default: 0.3, default: 0.3,

View File

@ -5,7 +5,11 @@
import { ref } from 'vue'; import { ref } from 'vue';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { globalEvents } from '@/events'; import { deepClone } from './clone.js';
import { globalEvents } from '@/events.js';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
import { miLocalStorage } from '@/local-storage.js';
export type Theme = { export type Theme = {
id: string; id: string;
@ -16,11 +20,6 @@ export type Theme = {
props: Record<string, string>; props: Record<string, string>;
}; };
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
import { deepClone } from './clone';
import { miLocalStorage } from '@/local-storage.js';
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
export const getBuiltinThemes = () => Promise.all( export const getBuiltinThemes = () => Promise.all(
@ -101,18 +100,11 @@ export function applyTheme(theme: Theme, persist = true) {
function compile(theme: Theme): Record<string, string> { function compile(theme: Theme): Record<string, string> {
function getColor(val: string): tinycolor.Instance { function getColor(val: string): tinycolor.Instance {
// ref (prop) if (val[0] === '@') { // ref (prop)
if (val[0] === '@') {
return getColor(theme.props[val.substring(1)]); return getColor(theme.props[val.substring(1)]);
} } else if (val[0] === '$') { // ref (const)
// ref (const)
else if (val[0] === '$') {
return getColor(theme.props[val]); return getColor(theme.props[val]);
} } else if (val[0] === ':') { // func
// func
else if (val[0] === ':') {
const parts = val.split('<'); const parts = val.split('<');
const func = parts.shift().substring(1); const func = parts.shift().substring(1);
const arg = parseFloat(parts.shift()); const arg = parseFloat(parts.shift());

View File

@ -54,9 +54,6 @@
infoWarnBg: '#42321c', infoWarnBg: '#42321c',
infoWarnFg: '#ffbd3e', infoWarnFg: '#ffbd3e',
switchBg: 'rgba(255, 255, 255, 0.15)', switchBg: 'rgba(255, 255, 255, 0.15)',
cwBg: '#687390',
cwFg: '#393f4f',
cwHoverBg: '#707b97',
buttonBg: 'rgba(255, 255, 255, 0.05)', buttonBg: 'rgba(255, 255, 255, 0.05)',
buttonHoverBg: 'rgba(255, 255, 255, 0.1)', buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
buttonGradateA: '@accent', buttonGradateA: '@accent',

View File

@ -54,9 +54,6 @@
infoWarnBg: '#fff0db', infoWarnBg: '#fff0db',
infoWarnFg: '#8f6e31', infoWarnFg: '#8f6e31',
switchBg: 'rgba(0, 0, 0, 0.15)', switchBg: 'rgba(0, 0, 0, 0.15)',
cwBg: '#b1b9c1',
cwFg: '#fff',
cwHoverBg: '#bbc4ce',
buttonBg: 'rgba(0, 0, 0, 0.05)', buttonBg: 'rgba(0, 0, 0, 0.05)',
buttonHoverBg: 'rgba(0, 0, 0, 0.1)', buttonHoverBg: 'rgba(0, 0, 0, 0.1)',
buttonGradateA: '@accent', buttonGradateA: '@accent',

View File

@ -6,8 +6,6 @@
props: { props: {
bg: '#232125', bg: '#232125',
fg: '#efdab9', fg: '#efdab9',
cwBg: '#687390',
cwFg: '#393f4f',
link: '#78b0a0', link: '#78b0a0',
warn: '#ecb637', warn: '#ecb637',
badge: '#31b1ce', badge: '#31b1ce',
@ -29,7 +27,6 @@
success: '#86b300', success: '#86b300',
buttonBg: 'rgba(255, 255, 255, 0.05)', buttonBg: 'rgba(255, 255, 255, 0.05)',
acrylicBg: ':alpha<0.5<@bg', acrylicBg: ':alpha<0.5<@bg',
cwHoverBg: '#707b97',
indicator: '@accent', indicator: '@accent',
mentionMe: '#fb5d38', mentionMe: '#fb5d38',
messageBg: '@bg', messageBg: '@bg',

View File

@ -21,8 +21,6 @@
X15: ':alpha<0<@panel', X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel', X16: ':alpha<0.7<@panel',
X17: ':alpha<0.8<@bg', X17: ':alpha<0.8<@bg',
cwBg: '#687390',
cwFg: '#393f4f',
link: '@accent', link: '@accent',
warn: '#ecb637', warn: '#ecb637',
badge: '#31b1ce', badge: '#31b1ce',
@ -46,7 +44,6 @@
buttonBg: 'rgba(255, 255, 255, 0.05)', buttonBg: 'rgba(255, 255, 255, 0.05)',
switchBg: 'rgba(255, 255, 255, 0.15)', switchBg: 'rgba(255, 255, 255, 0.15)',
acrylicBg: ':alpha<0.5<@bg', acrylicBg: ':alpha<0.5<@bg',
cwHoverBg: '#707b97',
indicator: '@accent', indicator: '@accent',
mentionMe: '@mention', mentionMe: '@mention',
messageBg: '@bg', messageBg: '@bg',

View File

@ -21,8 +21,6 @@
X15: ':alpha<0<@panel', X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel', X16: ':alpha<0.7<@panel',
X17: ':alpha<0.8<@bg', X17: ':alpha<0.8<@bg',
cwBg: '#687390',
cwFg: '#393f4f',
link: '@accent', link: '@accent',
warn: '#ecb637', warn: '#ecb637',
badge: '#31b1ce', badge: '#31b1ce',
@ -46,7 +44,6 @@
buttonBg: '#0000000d', buttonBg: '#0000000d',
switchBg: 'rgba(255, 255, 255, 0.15)', switchBg: 'rgba(255, 255, 255, 0.15)',
acrylicBg: ':alpha<0.5<@bg', acrylicBg: ':alpha<0.5<@bg',
cwHoverBg: '#707b97',
indicator: '@accent', indicator: '@accent',
mentionMe: '@mention', mentionMe: '@mention',
messageBg: '@bg', messageBg: '@bg',

View File

@ -9,8 +9,6 @@
props: { props: {
bg: '#fafafa', bg: '#fafafa',
fg: '#444', fg: '#444',
cwBg: '#b1b9c1',
cwFg: '#fff',
link: '#ff9400', link: '#ff9400',
warn: '#ecb637', warn: '#ecb637',
badge: '#31b1ce', badge: '#31b1ce',
@ -32,7 +30,6 @@
success: '#86b300', success: '#86b300',
buttonBg: 'rgba(0, 0, 0, 0.05)', buttonBg: 'rgba(0, 0, 0, 0.05)',
acrylicBg: ':alpha<0.5<@bg', acrylicBg: ':alpha<0.5<@bg',
cwHoverBg: '#bbc4ce',
indicator: '@accent', indicator: '@accent',
mentionMe: '@mention', mentionMe: '@mention',
messageBg: '@bg', messageBg: '@bg',

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