Merge branch 'misskey-dev:develop' into develop
This commit is contained in:
commit
6a44649eff
@ -4,7 +4,9 @@
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-contrib/features/pnpm:2": {},
|
||||
"ghcr.io/devcontainers-contrib/features/pnpm:2": {
|
||||
"version": "8.8.0"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20.5.1"
|
||||
}
|
||||
|
14
CHANGELOG.md
14
CHANGELOG.md
@ -14,26 +14,36 @@
|
||||
|
||||
## 2023.10.0
|
||||
### NOTE
|
||||
- muted_noteテーブルは使われなくなったため手動で削除を行ってください。
|
||||
- 2023.9.2で導入されたノート編集機能はクオリティの高い実装が困難であることが判明したため撤回されました
|
||||
- アップデート後、アップデートより前の時点にTLを遡ることはできません
|
||||
- アップデート後であっても、今後のアップデートで2023.10.0以前のTLに遡れるようになる可能性はあります
|
||||
|
||||
### Changes
|
||||
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
|
||||
- API: notes/global-timeline は現在常に `[]` を返します
|
||||
- API: notes/featured でページネーションは他APIと同様 untilId を使って行うようになりました
|
||||
|
||||
### General
|
||||
- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
|
||||
- Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました
|
||||
- Feat: ユーザーごとのハイライト
|
||||
- Enhance: ソフトワードミュートとハードワードミュートは統合されました
|
||||
- Enhance: モデレーションログ機能の強化
|
||||
- Enhance: ローカリゼーションの更新
|
||||
- Enhance: 依存関係の更新
|
||||
- Fix: ダイレクト投稿をリノートできてしまう問題を修正
|
||||
- Fix: ユーザーリストTLにチャンネル投稿が含まれる問題を修正
|
||||
|
||||
### Client
|
||||
- Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に
|
||||
- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正
|
||||
|
||||
### Server
|
||||
- Enhance: タイムライン取得時のパフォーマンスを改善
|
||||
- Enhance: タイムライン取得時のパフォーマンスを大幅に向上
|
||||
- Enhance: ハイライト取得時のパフォーマンスを大幅に向上
|
||||
- Enhance: トレンドハッシュタグ取得時のパフォーマンスを大幅に向上
|
||||
- Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上
|
||||
- Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正
|
||||
|
||||
## 2023.9.3
|
||||
### General
|
||||
|
@ -1184,11 +1184,6 @@ _wordMute:
|
||||
muteWords: "الكلمات المحظورة"
|
||||
muteWordsDescription: "افصل بينهم بمسافة لاستخدام معامل \"و\" أو بسطر لاستخدام معامل \"أو\"."
|
||||
muteWordsDescription2: "احصر الكلمات المفتاحية بين بين شرطتين مائلتين لاستخدامها كتعابير نمطية"
|
||||
softDescription: "اخف الملاحظات التي تستوف الشروط من الخيط الزمني."
|
||||
hardDescription: "اخف الملاحظات التي تستوف الشروط من الخيط الزمني.بالإضافة إلى أن هذه الملاحظات ستبقى مخفية حتى وإن تغيرت الشروط."
|
||||
soft: "لينة"
|
||||
hard: "قاسية"
|
||||
mutedNotes: "الملاحظات المكتومة"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "هذه سيحجب كل ملاحظات الخوادم المحجوبة ومشاركاتها والردود على تلك الملاحظات حتى وإن كانت من خادم غير محجوب."
|
||||
instanceMuteDescription2: "مدخلة لكل سطر"
|
||||
|
@ -932,11 +932,6 @@ _wordMute:
|
||||
muteWords: "নিঃশব্দ করা শব্দগুলি"
|
||||
muteWordsDescription: "স্পেস দিয়ে আলাদা করলে AND শর্ত তৈরি হবে এবং আলাদা লাইনে লিখলে OR শর্ত তৈরি হবে।"
|
||||
muteWordsDescription2: "রেগুলার এক্সপ্রেশন ব্যবহার করতে স্ল্যাশ দিয়ে কীওয়ার্ডকে ঘিরে রাখুন।"
|
||||
softDescription: "টাইমলাইন থেকে নির্দিষ্ট শর্তানুযায়ী নোট লুকিয়ে রাখে।"
|
||||
hardDescription: "নির্দিষ্ট শর্তানুযায়ী নোটগুলিকে টাইমলাইন থেকে বাদ দেয়। আপনি শর্ত পরিবর্তন করলেও যে নোটগুলি যোগ করা হয়নি সেগুলি বাদ দেওয়া হবে।"
|
||||
soft: "নমনীয়"
|
||||
hard: "কঠোর"
|
||||
mutedNotes: "মিউট করা নোটগুলি"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "কনফিগার করা ইন্সট্যান্সের সব নোট এবং রিনোট মিউট করুন, মিউট করা ইন্সট্যান্সের ব্যবহারকারীদের উত্তর সহ।"
|
||||
instanceMuteDescription2: "প্রতিটিকে আলাদা লাইনে লিখুন"
|
||||
|
@ -1559,11 +1559,6 @@ _wordMute:
|
||||
muteWords: "Ztlumená slova"
|
||||
muteWordsDescription: "Podmínku AND oddělujte mezerami, podmínku OR oddělujte řádkovými zlomy."
|
||||
muteWordsDescription2: "Chcete-li použít regulární výrazy, obklopte klíčová slova lomítky."
|
||||
softDescription: "Skrýt poznámky, které splňují nastavené podmínky, z časové osy."
|
||||
hardDescription: "Zabrání přidání poznámek splňujících nastavené podmínky na časovou osu. Kromě toho nebudou tyto poznámky přidány na časovou osu, ani když se podmínky změní."
|
||||
soft: "Měkký"
|
||||
hard: "Tvrdý"
|
||||
mutedNotes: "Ztlumené poznámky"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Tímhle se ztlumí všechny poznámky/poznámky z uvedených instancí, včetně poznámek uživatelů, kteří odpovídají uživateli ze ztlumené instance."
|
||||
instanceMuteDescription2: "Oddělte novými řádky"
|
||||
|
@ -1126,6 +1126,9 @@ edited: "Bearbeitet"
|
||||
notificationRecieveConfig: "Benachrichtigungseinstellungen"
|
||||
mutualFollow: "Gegenseitig gefolgt"
|
||||
fileAttachedOnly: "Nur Notizen mit Dateien"
|
||||
showRepliesToOthersInTimeline: "Antworten in Chronik anzeigen"
|
||||
hideRepliesToOthersInTimeline: "Antworten nicht in Chronik anzeigen"
|
||||
externalServices: "Externe Dienste"
|
||||
_announcement:
|
||||
forExistingUsers: "Nur für existierende Nutzer"
|
||||
forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt."
|
||||
@ -1456,7 +1459,6 @@ _role:
|
||||
gtlAvailable: "Kann auf die globale Chronik zugreifen"
|
||||
ltlAvailable: "Kann auf die lokale Chronik zugreifen"
|
||||
canPublicNote: "Kann öffentliche Notizen erstellen"
|
||||
canEditNote: "Notizbearbeitung"
|
||||
canInvite: "Erstellung von Einladungscodes für diese Instanz"
|
||||
inviteLimit: "Maximalanzahl an Einladungen"
|
||||
inviteLimitCycle: "Zyklus des Einladungslimits"
|
||||
@ -1476,6 +1478,7 @@ _role:
|
||||
descriptionOfRateLimitFactor: "Je niedriger desto weniger restriktiv, je höher destro restriktiver."
|
||||
canHideAds: "Kann Werbung ausblenden"
|
||||
canSearchNotes: "Nutzung der Notizsuchfunktion"
|
||||
canUseTranslator: "Verwendung des Übersetzers"
|
||||
_condition:
|
||||
isLocal: "Lokaler Benutzer"
|
||||
isRemote: "Benutzer fremder Instanz"
|
||||
@ -1609,11 +1612,6 @@ _wordMute:
|
||||
muteWords: "Stummgeschaltete Wörter"
|
||||
muteWordsDescription: "Zum Nutzen einer \"UND\"-Verknüpfung Einträge mit Leerzeichen trennen, zum Nutzen einer \"ODER\"-Verknüpfung Einträge mit einem Zeilenumbruch trennen."
|
||||
muteWordsDescription2: "Umgib Schlüsselworter mit Schrägstrichen, um Reguläre Ausdrücke zu verwenden."
|
||||
softDescription: "Notizen, die die angegebenen Konditionen erfüllen, in der Chronik ausblenden."
|
||||
hardDescription: "Verhindern, dass Notizen, die die angegebenen Konditionen erfüllen, der Chronik hinzugefügt werden. Zudem werden diese Notizen auch nicht der Chronik hinzugefügt, falls die Konditionen geändert werden."
|
||||
soft: "Leicht"
|
||||
hard: "Schwer"
|
||||
mutedNotes: "Stummgeschaltete Notizen"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Schaltet alle Notizen/Renotes stumm, die von den gelisteten Instanzen stammen, inklusive Antworten von Benutzern an einen Benutzer einer stummgeschalteten Instanz."
|
||||
instanceMuteDescription2: "Instanzen getrennt durch Zeilenumbrüchen angeben"
|
||||
|
@ -1126,6 +1126,9 @@ edited: "Edited"
|
||||
notificationRecieveConfig: "Notification Settings"
|
||||
mutualFollow: "Mutual follow"
|
||||
fileAttachedOnly: "Only notes with files"
|
||||
showRepliesToOthersInTimeline: "Show replies to others in TL"
|
||||
hideRepliesToOthersInTimeline: "Hide replies to others from TL"
|
||||
externalServices: "External Services"
|
||||
_announcement:
|
||||
forExistingUsers: "Existing users only"
|
||||
forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it."
|
||||
@ -1456,7 +1459,6 @@ _role:
|
||||
gtlAvailable: "Can view the global timeline"
|
||||
ltlAvailable: "Can view the local timeline"
|
||||
canPublicNote: "Can send public notes"
|
||||
canEditNote: "Note editing"
|
||||
canInvite: "Can create instance invite codes"
|
||||
inviteLimit: "Invite limit"
|
||||
inviteLimitCycle: "Invite limit cooldown"
|
||||
@ -1476,6 +1478,7 @@ _role:
|
||||
descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. "
|
||||
canHideAds: "Can hide ads"
|
||||
canSearchNotes: "Usage of note search"
|
||||
canUseTranslator: "Translator usage"
|
||||
_condition:
|
||||
isLocal: "Local user"
|
||||
isRemote: "Remote user"
|
||||
@ -1609,11 +1612,6 @@ _wordMute:
|
||||
muteWords: "Muted words"
|
||||
muteWordsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition."
|
||||
muteWordsDescription2: "Surround keywords with slashes to use regular expressions."
|
||||
softDescription: "Hide notes that fulfil the set conditions from the timeline."
|
||||
hardDescription: "Prevents notes fulfilling the set conditions from being added to the timeline. In addition, these notes will not be added to the timeline even if the conditions are changed."
|
||||
soft: "Soft"
|
||||
hard: "Hard"
|
||||
mutedNotes: "Muted notes"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "This will mute any notes/renotes from the listed instances, including those of users replying to a user from a muted instance."
|
||||
instanceMuteDescription2: "Separate with newlines"
|
||||
|
@ -1603,11 +1603,6 @@ _wordMute:
|
||||
muteWords: "Palabras que silenciar"
|
||||
muteWordsDescription: "Separar con espacios indica una declaracion And, separar con lineas nuevas indica una declaracion Or。"
|
||||
muteWordsDescription2: "Encerrar las palabras clave entre numerales para usar expresiones regulares"
|
||||
softDescription: "Ocultar en la linea de tiempo las notas que cumplen las condiciones"
|
||||
hardDescription: "Evitar que se agreguen a la linea de tiempo las notas que cumplen las condiciones. Las notas no agregadas seguirán quitadas aunque cambien las condiciones."
|
||||
soft: "Suave"
|
||||
hard: "Duro"
|
||||
mutedNotes: "Notas silenciadas"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Silencia todas las notas y reposts de la instancias seleccionadas, incluyendo respuestas a los usuarios de las mismas"
|
||||
instanceMuteDescription2: "Separar por líneas"
|
||||
|
@ -1267,11 +1267,6 @@ _wordMute:
|
||||
muteWords: "Mots à filtrer"
|
||||
muteWordsDescription: "Séparer avec des espaces pour la condition AND. Séparer avec un saut de ligne pour une condition OR."
|
||||
muteWordsDescription2: "Pour utiliser des expressions régulières (regex), mettez les mots-clés entre barres obliques."
|
||||
softDescription: "Masquez les notes de votre fil selon les paramètres que vous définissez."
|
||||
hardDescription: "Empêchez votre fil de charger les notes selon les paramètres que vous définissez. Cette action est irréversible : si vous modifiez ces paramètres plus tard, les notes précédemment filtrées ne seront pas récupérées."
|
||||
soft: "Doux"
|
||||
hard: "Strict"
|
||||
mutedNotes: "Notes filtrées"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Met en sourdine toutes les notes et renotes de l'instance configurée, y compris les réponses aux utilisateurs de l'instance muette."
|
||||
instanceMuteDescription2: "Séparer avec de nouvelles lignes"
|
||||
|
@ -1564,11 +1564,6 @@ _wordMute:
|
||||
muteWords: "Kata yang dibisukan"
|
||||
muteWordsDescription: "Pisahkan dengan spasi untuk kondisi AND. Pisahkan dengan baris baru untuk kondisi OR."
|
||||
muteWordsDescription2: "Kurung kata kunci dengan garis miring untuk menggunakan ekspresi reguler."
|
||||
softDescription: "Sembunyikan catatan yang memenuhi aturan kondisi dari lini masa."
|
||||
hardDescription: "Cegah catatan memenuhi aturan kondisi dari ditambahkan ke lini masa. Dengan tambahan, catatan berikut tidak akan ditambahkan ke lini masa meskipun jika kondisi tersebut diubah."
|
||||
soft: "Lembut"
|
||||
hard: "Keras"
|
||||
mutedNotes: "Catatan yang dibisukan"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Pengaturan ini akan membisukan note/renote apa saja dari instansi yang terdaftar, termasuk pengguna yang membalas pengguna lain dalam instansi yang dibisukan."
|
||||
instanceMuteDescription2: "Pisah dengan baris baru"
|
||||
|
@ -78,7 +78,7 @@ download: "Scarica"
|
||||
driveFileDeleteConfirm: "Vuoi davvero eliminare il file \"{name}\", e le Note a cui è stato allegato?"
|
||||
unfollowConfirm: "Vuoi davvero smettere di seguire {name}?"
|
||||
exportRequested: "Hai richiesto un'esportazione, e potrebbe volerci tempo. Quando sarà compiuta, il file verrà aggiunto direttamente al Drive."
|
||||
importRequested: "Hai richiesto un'importazione. Può volerci tempo. "
|
||||
importRequested: "Hai richiesto un'importazione. Potrebbe richiedere un po' di tempo."
|
||||
lists: "Liste"
|
||||
noLists: "Nessuna lista"
|
||||
note: "Nota"
|
||||
@ -186,7 +186,7 @@ recipient: "Destinatario"
|
||||
annotation: "Annotazione preventiva"
|
||||
federation: "Federazione"
|
||||
instances: "Istanza"
|
||||
registeredAt: "Registrato presso"
|
||||
registeredAt: "Prima federazione"
|
||||
latestRequestReceivedAt: "Ultima richiesta ricevuta"
|
||||
latestStatus: "Ultimo stato"
|
||||
storageUsage: "Capienza dei dischi"
|
||||
@ -461,7 +461,7 @@ invitationCode: "Codice di invito"
|
||||
checking: "Confermando"
|
||||
available: "Disponibile"
|
||||
unavailable: "Il nome utente è già in uso"
|
||||
usernameInvalidFormat: "Il nome utente può contenere solo lettere, numeri e '_'"
|
||||
usernameInvalidFormat: "Il nome utente deve avere solo caratteri alfanumerici e trattino basso '_'"
|
||||
tooShort: "Troppo breve"
|
||||
tooLong: "Troppo lungo"
|
||||
weakPassword: "Password debole"
|
||||
@ -1122,6 +1122,13 @@ authentication: "Autenticazione"
|
||||
authenticationRequiredToContinue: "Per procedere, è richiesta l'autenticazione"
|
||||
dateAndTime: "Data e Ora"
|
||||
showRenotes: "Leggi le Rinota"
|
||||
edited: "Modificato"
|
||||
notificationRecieveConfig: "Preferenze di notifica"
|
||||
mutualFollow: "Follow reciproco"
|
||||
fileAttachedOnly: "Con file in allegato"
|
||||
showRepliesToOthersInTimeline: "Risposte altrui nella TL"
|
||||
hideRepliesToOthersInTimeline: "Nascondi Riposte altrui nella TL"
|
||||
externalServices: "Servizi esterni"
|
||||
_announcement:
|
||||
forExistingUsers: "Solo ai profili attuali"
|
||||
forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio."
|
||||
@ -1451,14 +1458,14 @@ _role:
|
||||
_options:
|
||||
gtlAvailable: "Disponibilità della Timeline Federata"
|
||||
ltlAvailable: "Disponibilità della Timeline Locale"
|
||||
canPublicNote: "Può scrivere Note con Visibilità Pubblica"
|
||||
canInvite: "Genera codici di invito all'istanza"
|
||||
canPublicNote: "Scrivere Note con Visibilità Pubblica"
|
||||
canInvite: "Generare codici di invito all'istanza"
|
||||
inviteLimit: "Limite di codici invito"
|
||||
inviteLimitCycle: "Intervallo di emissione del codice di invito"
|
||||
inviteExpirationTime: "Scadenza del codice di invito"
|
||||
canManageCustomEmojis: "Gestire le emoji personalizzate"
|
||||
driveCapacity: "Capienza del Drive"
|
||||
alwaysMarkNsfw: "Imposta sempre come NSFW"
|
||||
alwaysMarkNsfw: "Impostare sempre come esplicito (NSFW)"
|
||||
pinMax: "Quantità massima di Note in primo piano"
|
||||
antennaMax: "Quantità massima di Antenne"
|
||||
wordMuteMax: "Lunghezza massima del filtro parole"
|
||||
@ -1469,8 +1476,9 @@ _role:
|
||||
userEachUserListsMax: "Quantità massima di profili per lista"
|
||||
rateLimitFactor: "Limite del rapporto"
|
||||
descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più."
|
||||
canHideAds: "Può nascondere i banner"
|
||||
canHideAds: "Nascondere i banner"
|
||||
canSearchNotes: "Ricercare nelle Note"
|
||||
canUseTranslator: "Tradurre le Note"
|
||||
_condition:
|
||||
isLocal: "Profilo locale"
|
||||
isRemote: "Profilo remoto"
|
||||
@ -1604,11 +1612,6 @@ _wordMute:
|
||||
muteWords: "Parole da filtrare"
|
||||
muteWordsDescription: "Separare con uno spazio indica la condizione \"E\". Separare con una interruzione di riga, indica la condizione \"O\""
|
||||
muteWordsDescription2: "Se vuoi indicare delle Espressioni Regolari (regexp), metti la condizione all'interno di due slash (/)"
|
||||
softDescription: "Verranno nascoste da tutte le Timeline quelle Note che soddisfano le seguenti condizioni"
|
||||
hardDescription: "Impedisci alla istanza di caricare Note che soddisfano le seguenti condizioni. Le Note già filtrate sono già scomparse in modo irreversibile, fino al cambiamento delle condizioni. Dopo di che scompariranno quelle che soddisfano le nuove condizioni."
|
||||
soft: "Leggero"
|
||||
hard: "Pesante"
|
||||
mutedNotes: "Note filtrate"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Disattiva tutte le note, le note di rinvio (condivisione) dell'istanza configurata, comprese le risposte agli utenti dell'istanza."
|
||||
instanceMuteDescription2: "Impostazione separata da una nuova riga"
|
||||
@ -2128,3 +2131,6 @@ _moderationLogTypes:
|
||||
unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito"
|
||||
resolveAbuseReport: "Segnalazione risolta"
|
||||
createInvitation: "Genera codice di invito"
|
||||
createAd: "Banner creato"
|
||||
deleteAd: "Banner eliminato"
|
||||
updateAd: "Banner aggiornato"
|
||||
|
@ -1586,11 +1586,6 @@ _wordMute:
|
||||
muteWords: "ミュートするワード"
|
||||
muteWordsDescription: "スペースで区切るとAND指定になって、改行で区切るとOR指定になるで。"
|
||||
muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になるで。"
|
||||
softDescription: "指定した条件のノートをタイムラインから隠すで。"
|
||||
hardDescription: "指定した条件のノートをタイムラインに追加しないようにするで。追加せーへんかったかったノートは、条件を変えても除外されたままになるで。"
|
||||
soft: "ソフト"
|
||||
hard: "ハード"
|
||||
mutedNotes: "ミュートされたノート"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートにするで。"
|
||||
instanceMuteDescription2: "改行で区切って設定するんやで"
|
||||
|
@ -1600,11 +1600,6 @@ _wordMute:
|
||||
muteWords: "뮤트할 단어"
|
||||
muteWordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정됩니다."
|
||||
muteWordsDescription2: "정규 표현식을 사용하려면 키워드를 빗금표(/)로 감싸 주세요."
|
||||
softDescription: "지정한 조건의 노트를 타임라인에서 숨깁니다."
|
||||
hardDescription: "지정한 조건의 노트를 타임라인에 추가하지 않습니다. 타임라인에 추가되지 않은 노트는 조건을 변경해도 표시되지 않습니다."
|
||||
soft: "보통"
|
||||
hard: "보다 높은 수준"
|
||||
mutedNotes: "뮤트된 노트"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "뮤트한 서버에서 오는 답글을 포함한 모든 노트와 Renote를 뮤트합니다."
|
||||
instanceMuteDescription2: "한 줄에 하나씩 입력해 주세요"
|
||||
|
@ -575,9 +575,6 @@ _channel:
|
||||
nameAndDescription: "Navn og beskrivelse"
|
||||
_menuDisplay:
|
||||
hide: "Skjul"
|
||||
_wordMute:
|
||||
soft: "Myk"
|
||||
hard: "Hard"
|
||||
_theme:
|
||||
description: "Beskrivelse"
|
||||
color: "Farge"
|
||||
|
@ -982,9 +982,6 @@ _menuDisplay:
|
||||
_wordMute:
|
||||
muteWords: "Słowo do wyciszenia"
|
||||
muteWordsDescription2: "Otocz słowa kluczowe ukośnikami, aby używać wyrażeń regularnych."
|
||||
soft: "Łagodny"
|
||||
hard: "Twardy"
|
||||
mutedNotes: "Wyciszone wpisy"
|
||||
_instanceMute:
|
||||
title: "Ukrywa wpisy z wymienionych instancji."
|
||||
heading: "Lista instancji do wyciszenia"
|
||||
|
@ -1488,11 +1488,6 @@ _wordMute:
|
||||
muteWords: "Скрыть слово"
|
||||
muteWordsDescription: "Пишите слова через пробел в одной строке, чтобы фильтровать их появление вместе; а если хотите фильтровать любое из них, пишите в отдельных строках."
|
||||
muteWordsDescription2: "Здесь можно использовать регулярные выражения — просто заключите их между двумя дробными чертами (/)."
|
||||
softDescription: "Соответствующие условиям заметки будут спрятаны из вашей ленты."
|
||||
hardDescription: "Соответстующие условиям заметки вообще не будут попадать в вашу ленту. Даже если вы поменяете условия, отсеенные таким образом заметки уже не появятся."
|
||||
soft: "Мягко"
|
||||
hard: "Жёстко"
|
||||
mutedNotes: "Скрытые заметки"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Заметки и репосты с указанных здесь инстансов, а также ответы пользователям оттуда же не будут отображаться."
|
||||
instanceMuteDescription2: "Пишите каждый инстанс на отдельной строке"
|
||||
|
@ -1039,11 +1039,6 @@ _wordMute:
|
||||
muteWords: "Umlčané slová"
|
||||
muteWordsDescription: "Medzerami oddeľte pre podmienku AND a novými riadkami pre podmienku OR."
|
||||
muteWordsDescription2: "Regulárne výrazy sa použijú keď použijete okolo lomítka."
|
||||
softDescription: "Skryje poznámky z časovej osi, ktoré spĺňajú podmienky."
|
||||
hardDescription: "Zabráni poznámky spĺňajúce množinu podmienok, aby boli pridané do časovej osi. Navyše tieto poznámky nepribudnú v časovej osi ani keď sa podmienky zmenia."
|
||||
soft: "Mäkké"
|
||||
hard: "Tvrdé"
|
||||
mutedNotes: "Umlčané poznámky"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Toto umlčí všetky poznámky/preposlania zo zoznamu serverov, vrátane tých, na ktoré používatelia odpovedajú z umlčaného servera."
|
||||
instanceMuteDescription2: "Oddeľte novými riadkami"
|
||||
|
@ -1150,6 +1150,7 @@ _serverRules:
|
||||
description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ"
|
||||
_serverSettings:
|
||||
iconUrl: "ไอคอน URL"
|
||||
manifestJsonOverride: "manifest.json โอเวอร์ลาย"
|
||||
shortName: "ชื่อย่อ"
|
||||
_accountMigration:
|
||||
moveFrom: "ย้ายข้อมูลบัญชีอื่นไปยังอีกบัญชีนี้หนึ่ง"
|
||||
@ -1407,6 +1408,7 @@ _achievements:
|
||||
flavor: "Misskey-Misskey La-Tu-Ma"
|
||||
_smashTestNotificationButton:
|
||||
title: "ทดสอบโอเวอร์โฟลว์"
|
||||
description: "ทดสอบการแจ้งเตือนทริกเกอร์ซ้ำๆ ภายในระยะเวลาอันสั้นๆ"
|
||||
_role:
|
||||
new: "บทบาทใหม่"
|
||||
edit: "แก้ไขบทบาท"
|
||||
@ -1445,7 +1447,6 @@ _role:
|
||||
gtlAvailable: "การดูไทม์ไลน์ทั่วโลก"
|
||||
ltlAvailable: "การดูไทม์ไลน์ในท้องถิ่น"
|
||||
canPublicNote: "สามารถส่งโน้ตสาธารณะ"
|
||||
canEditNote: "กำลังแก้ไขโน้ต"
|
||||
canInvite: "สร้างรหัสเชิญอินสแตนซ์"
|
||||
inviteLimit: "จำกัดการเชิญ"
|
||||
inviteLimitCycle: "จำกัดการเชิญไว้คูลดาวน์"
|
||||
@ -1465,6 +1466,7 @@ _role:
|
||||
descriptionOfRateLimitFactor: "ขีดจํากัดอัตราที่ต่ำกว่ามีข้อจํากัดน้อยกว่าข้อจํากัดที่สูงกว่า"
|
||||
canHideAds: "ซ่อนโฆษณา"
|
||||
canSearchNotes: "การใช้การค้นหาโน้ต"
|
||||
canUseTranslator: "การใช้งานแปล"
|
||||
_condition:
|
||||
isLocal: "ผู้ใช้ภายใน"
|
||||
isRemote: "ผู้ใช้ระยะไกล"
|
||||
@ -1598,11 +1600,6 @@ _wordMute:
|
||||
muteWords: "ปิดเสียงคำ"
|
||||
muteWordsDescription: "คั่นด้วยช่องว่างสำหรับเงื่อนไข AND หรือด้วยการขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR นะ"
|
||||
muteWordsDescription2: "ล้อมรอบคีย์เวิร์ดด้วยเครื่องหมายทับเพื่อใช้นิพจน์ทั่วไป"
|
||||
softDescription: "ซ่อนโน้ตให้ตรงตามเงื่อนไขที่ตั้งไว้จากไทม์ไลน์"
|
||||
hardDescription: "ป้องกันไม่ให้โน้ตย่อที่ตรงตามเงื่อนไขที่ตั้งไว้ไม่ให้ถูกเพิ่มลงในไทม์ไลน์ นอกจากนี้ โน้ตเหล่านี้จะไม่ถูกเพิ่มลงในไทม์ไลน์แม้ว่าจะมีการเปลี่ยนแปลงเงื่อนไขยังไงก็ตาม"
|
||||
soft: "ซอฟ"
|
||||
hard: "ยาก"
|
||||
mutedNotes: "ปิดเสียงโน้ต"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "การดำเนินการนี้จะปิดเสียง\"โน้ต/รีโน้ต\"จากอินสแตนซ์ที่อยู่ในรายการ รวมถึงบันทึกของผู้ใช้ที่ตอบกลับผู้ใช้จากอินสแตนซ์ที่ปิดเสียง"
|
||||
instanceMuteDescription2: "คั่นด้วยการขึ้นบรรทัดใหม่"
|
||||
@ -1790,6 +1787,7 @@ _antennaSources:
|
||||
homeTimeline: "โน้ตจากผู้ใช้ที่ติดตาม"
|
||||
users: "โน้ตจากผู้ใช้ที่เฉพาะเจาะจง"
|
||||
userList: "โน้ตจากรายชื่อผู้ใช้ที่ระบุ"
|
||||
userBlacklist: "โน้ตทั้งหมดยกเว้นโน้ตของผู้ใช้ที่ต้องระบุเจาะจงตั้งแต่หนึ่งรายขึ้นไป"
|
||||
_weekday:
|
||||
sunday: "วันอาทิตย์"
|
||||
monday: "วันจันทร์"
|
||||
@ -1889,6 +1887,7 @@ _profile:
|
||||
metadataContent: "เนื้อหา"
|
||||
changeAvatar: "เปลี่ยนอวาตาร์"
|
||||
changeBanner: "เปลี่ยนแบนเนอร์"
|
||||
verifiedLinkDescription: "โดยการป้อน URL ที่มีลิงก์ไปยังโปรไฟล์ของคุณตรงนี้ ส่วนไอคอนการยืนยันความเป็นเจ้าของนั้นก็สามารถแสดงถัดจากฟิลด์ได้นะ"
|
||||
_exportOrImport:
|
||||
allNotes: "โน้ตทั้งหมด"
|
||||
favoritedNotes: "บันทึกที่ชื่นชอบ"
|
||||
@ -2102,7 +2101,17 @@ _moderationLogTypes:
|
||||
updateUserNote: "อัปเดตโน้ตการกลั่นกรองแล้ว"
|
||||
deleteDriveFile: "ลบไฟล์ออกแล้ว"
|
||||
deleteNote: "ลบโน้ตออกแล้ว"
|
||||
createGlobalAnnouncement: "สร้างประกาศทั่วโลกแล้ว"
|
||||
createUserAnnouncement: "สร้างประกาศผู้ใช้แล้ว"
|
||||
updateGlobalAnnouncement: "อัปเดตประกาศทั่วโลกแล้ว"
|
||||
updateUserAnnouncement: "อัปเดตประกาศผู้ใช้แล้ว"
|
||||
deleteGlobalAnnouncement: "ลบประกาศทั่วโลกออกแล้ว"
|
||||
deleteUserAnnouncement: "ลบประกาศผู้ใช้ออกแล้ว"
|
||||
resetPassword: "รีเซ็ตรหัสผ่าน"
|
||||
suspendRemoteInstance: "อินสแตนซ์ระยะไกลถูกระงับ"
|
||||
unsuspendRemoteInstance: "อินสแตนซ์ระยะไกลเลิกการระงับ"
|
||||
markSensitiveDriveFile: "ทำเครื่องหมายไฟล์บอกว่าละเอียดอ่อน"
|
||||
unmarkSensitiveDriveFile: "ยกเลิกทำเครื่องหมายไฟล์ว่าละเอียดอ่อน"
|
||||
resolveAbuseReport: "รายงานได้รับการแก้ไขแล้ว"
|
||||
createInvitation: "สร้างคำเชิญ"
|
||||
createAd: "สร้างโฆษณาแล้ว"
|
||||
|
@ -1233,11 +1233,6 @@ _wordMute:
|
||||
muteWords: "Заглушені слова"
|
||||
muteWordsDescription: "Розділення ключових слів пробілами для \"І\" або з нової лінійки для \"АБО\""
|
||||
muteWordsDescription2: "Для використання RegEx, ключові слова потрібно вписати поміж слешів \"/\"."
|
||||
softDescription: "Приховати записи які відповідають критеріям зі стрічки подій."
|
||||
hardDescription: "Приховати записи які відповідають критеріям зі стрічки подій. Також приховані записи не будуть додані до стрічки подій навіть якщо критерії буде змінено."
|
||||
soft: "М'яко"
|
||||
hard: "Жорстко"
|
||||
mutedNotes: "Заблоковані нотатки"
|
||||
_instanceMute:
|
||||
instanceMuteDescription2: "Розділяйте новими рядками"
|
||||
title: "Приховує нотатки з перелічених інстансів."
|
||||
|
@ -1404,11 +1404,6 @@ _wordMute:
|
||||
muteWords: "Ẩn từ ngữ"
|
||||
muteWordsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition."
|
||||
muteWordsDescription2: "Bao quanh các từ khóa bằng dấu gạch chéo để sử dụng cụm từ thông dụng."
|
||||
softDescription: "Ẩn các tút phù hợp điều kiện đã đặt khỏi bảng tin."
|
||||
hardDescription: "Ngăn các tút đáp ứng các điều kiện đã đặt xuất hiện trên bảng tin. Lưu ý, những tút này sẽ không được thêm vào bảng tin ngay cả khi các điều kiện được thay đổi."
|
||||
soft: "Yếu"
|
||||
hard: "Mạnh"
|
||||
mutedNotes: "Những tút đã ẩn"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Thao tác này sẽ ẩn mọi tút/lượt đăng lại từ các máy chủ được liệt kê, bao gồm cả những tút dạng trả lời từ máy chủ bị ẩn."
|
||||
instanceMuteDescription2: "Tách bằng cách xuống dòng"
|
||||
|
@ -1126,6 +1126,8 @@ edited: "已编辑"
|
||||
notificationRecieveConfig: "通知接收设置"
|
||||
mutualFollow: "互相关注"
|
||||
fileAttachedOnly: "仅限媒体"
|
||||
showRepliesToOthersInTimeline: "在时间线上显示给其他人的回复"
|
||||
hideRepliesToOthersInTimeline: "在时间线上隐藏给其他人的回复"
|
||||
_announcement:
|
||||
forExistingUsers: "仅限现有用户"
|
||||
forExistingUsersDescription: "若启用,该公告将仅对创建此公告时存在的用户可见。 如果禁用,则在创建此公告后注册的用户也可以看到该公告。"
|
||||
@ -1456,7 +1458,6 @@ _role:
|
||||
gtlAvailable: "查看全局时间线"
|
||||
ltlAvailable: "查看本地时间线"
|
||||
canPublicNote: "允许公开发帖"
|
||||
canEditNote: "编辑帖子"
|
||||
canInvite: "发放服务器邀请码"
|
||||
inviteLimit: "可发行邀请码的数量"
|
||||
inviteLimitCycle: "邀请码的发行间隔"
|
||||
@ -1609,11 +1610,6 @@ _wordMute:
|
||||
muteWords: "禁用词"
|
||||
muteWordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。"
|
||||
muteWordsDescription2: "正则表达式用斜线包裹"
|
||||
softDescription: "隐藏时间线中指定条件的帖子。"
|
||||
hardDescription: "防止将具有指定条件的帖子添加到时间线。 即使您更改条件,未添加的帖文也会被排除在外。"
|
||||
soft: "软屏蔽"
|
||||
hard: "硬屏蔽"
|
||||
mutedNotes: "被屏蔽的帖子"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "屏蔽服务器中的所有帖子和转帖,包括这些服务器上的用户回复。"
|
||||
instanceMuteDescription2: "一行一个"
|
||||
|
@ -156,7 +156,7 @@ emojiUrl: "表情符號 URL"
|
||||
addEmoji: "新增表情符號"
|
||||
settingGuide: "推薦設定"
|
||||
cacheRemoteFiles: "快取遠端檔案"
|
||||
cacheRemoteFilesDescription: "啟用此設定後,遠端檔案會被快取在本伺服器的儲存空間中。雖然顯示圖片會變快,但會消耗較多伺服器的儲存空間。至於要快取遠端使用者到什麼程度,是依照角色的雲端硬碟容量而定。當超過這個限制時,從較舊的檔案開始自快取中刪除並改為連結。關閉這個設定時,遠端檔案從一開始就維持連結的方式,但建議將 default.yml 的 proxyRemoteFiles 設為 true,以便產生圖片的縮圖並保護使用者的隱私,。"
|
||||
cacheRemoteFilesDescription: "啟用此設定後,遠端檔案會被快取在本伺服器的儲存空間中。雖然顯示圖片會變快,但會消耗較多伺服器的儲存空間。至於要快取遠端使用者到什麼程度,是依照角色的雲端硬碟容量而定。當超過這個限制時,從較舊的檔案開始自快取中刪除並改為連結。關閉這個設定時,遠端檔案從一開始就維持連結的方式,但建議將 default.yml 的 proxyRemoteFiles 設為 true,以便產生圖片的縮圖並保護使用者的隱私。"
|
||||
youCanCleanRemoteFilesCache: "按檔案管理的🗑️按鈕,可將快取全部刪除。"
|
||||
cacheRemoteSensitiveFiles: "快取遠端的敏感檔案"
|
||||
cacheRemoteSensitiveFilesDescription: "若停用這個設定,則不會快取遠端的敏感檔案,而是直接連結。"
|
||||
@ -1123,7 +1123,12 @@ authenticationRequiredToContinue: "請於繼續前完成驗證"
|
||||
dateAndTime: "日期與時間"
|
||||
showRenotes: "顯示轉發貼文"
|
||||
edited: "已編輯"
|
||||
notificationRecieveConfig: "接受通知的設定"
|
||||
mutualFollow: "互相追隨"
|
||||
fileAttachedOnly: "包含附件"
|
||||
showRepliesToOthersInTimeline: "在時間軸上顯示給其他人的回覆"
|
||||
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
|
||||
externalServices: "外部服務"
|
||||
_announcement:
|
||||
forExistingUsers: "僅限既有的使用者"
|
||||
forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。"
|
||||
@ -1454,7 +1459,6 @@ _role:
|
||||
gtlAvailable: "瀏覽全域時間軸"
|
||||
ltlAvailable: "瀏覽本地時間軸"
|
||||
canPublicNote: "允許公開貼文"
|
||||
canEditNote: "允許編輯貼文"
|
||||
canInvite: "發行實例邀請碼"
|
||||
inviteLimit: "可建立邀請碼的數量"
|
||||
inviteLimitCycle: "邀請碼的發放間隔"
|
||||
@ -1474,6 +1478,7 @@ _role:
|
||||
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
|
||||
canHideAds: "不顯示廣告"
|
||||
canSearchNotes: "可否搜尋貼文"
|
||||
canUseTranslator: "使用翻譯功能"
|
||||
_condition:
|
||||
isLocal: "本地使用者"
|
||||
isRemote: "遠端使用者"
|
||||
@ -1607,11 +1612,6 @@ _wordMute:
|
||||
muteWords: "加入靜音文字"
|
||||
muteWordsDescription: "空格代表「以及」(AND),換行代表「或者」(OR)。"
|
||||
muteWordsDescription2: "用斜線包圍關鍵字代表正規表達式。"
|
||||
softDescription: "隱藏時間軸中符合特定條件的貼文。"
|
||||
hardDescription: "符合特定條件的貼文將不會新增至時間軸。 即使您更改條件,未被新增的貼文也會被排除在外。"
|
||||
soft: "軟性靜音"
|
||||
hard: "硬性靜音"
|
||||
mutedNotes: "已靜音的貼文"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "包括對被靜音實例上的使用者的回覆,被設定的實例上所有貼文及轉發都會被靜音。"
|
||||
instanceMuteDescription2: "設定時以換行進行分隔"
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2023.10.0-beta.2",
|
||||
"version": "2023.10.0-beta.5",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -47,12 +47,12 @@
|
||||
"cssnano": "6.0.1",
|
||||
"js-yaml": "4.1.0",
|
||||
"postcss": "8.4.31",
|
||||
"terser": "5.20.0",
|
||||
"terser": "5.21.0",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "6.7.3",
|
||||
"@typescript-eslint/parser": "6.7.3",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.3.0",
|
||||
"eslint": "8.50.0",
|
||||
|
18
packages/backend/migration/1696569742153-clean-up.js
Normal file
18
packages/backend/migration/1696569742153-clean-up.js
Normal file
@ -0,0 +1,18 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class CleanUp1696569742153 {
|
||||
name = 'CleanUp1696569742153'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_01f4581f114e0ebd2bbb876f0b"`);
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "score"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note" ADD "score" integer NOT NULL DEFAULT '0'`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_01f4581f114e0ebd2bbb876f0b" ON "note_reaction" ("createdAt") `);
|
||||
}
|
||||
}
|
15
packages/backend/migration/1696581429196-clean-up.js
Normal file
15
packages/backend/migration/1696581429196-clean-up.js
Normal file
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class CleanUp1696581429196 {
|
||||
name = 'CleanUp1696581429196'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "muted_note"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
}
|
||||
}
|
@ -71,14 +71,14 @@
|
||||
"@fastify/multipart": "8.0.0",
|
||||
"@fastify/static": "6.11.2",
|
||||
"@fastify/view": "8.2.0",
|
||||
"@nestjs/common": "10.2.6",
|
||||
"@nestjs/core": "10.2.6",
|
||||
"@nestjs/testing": "10.2.6",
|
||||
"@nestjs/common": "10.2.7",
|
||||
"@nestjs/core": "10.2.7",
|
||||
"@nestjs/testing": "10.2.7",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@simplewebauthn/server": "8.2.0",
|
||||
"@sinonjs/fake-timers": "11.1.0",
|
||||
"@swc/cli": "0.1.62",
|
||||
"@swc/core": "1.3.90",
|
||||
"@swc/core": "1.3.92",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "6.0.1",
|
||||
@ -86,7 +86,7 @@
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.2",
|
||||
"bullmq": "4.11.4",
|
||||
"bullmq": "4.12.2",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.1",
|
||||
"chalk": "5.3.0",
|
||||
@ -155,7 +155,7 @@
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"systeminformation": "5.21.9",
|
||||
"systeminformation": "5.21.11",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.1",
|
||||
"tsc-alias": "1.8.8",
|
||||
@ -189,7 +189,7 @@
|
||||
"@types/jsrsasign": "10.5.9",
|
||||
"@types/mime-types": "2.1.2",
|
||||
"@types/ms": "0.7.32",
|
||||
"@types/node": "20.7.1",
|
||||
"@types/node": "20.8.2",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.11",
|
||||
"@types/oauth": "0.9.2",
|
||||
@ -212,8 +212,8 @@
|
||||
"@types/vary": "1.1.1",
|
||||
"@types/web-push": "3.6.1",
|
||||
"@types/ws": "8.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.3",
|
||||
"@typescript-eslint/parser": "6.7.3",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"aws-sdk-client-mock": "3.0.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.50.0",
|
||||
|
@ -60,6 +60,7 @@ import { UtilityService } from './UtilityService.js';
|
||||
import { FileInfoService } from './FileInfoService.js';
|
||||
import { SearchService } from './SearchService.js';
|
||||
import { ClipService } from './ClipService.js';
|
||||
import { FeaturedService } from './FeaturedService.js';
|
||||
import { ChartLoggerService } from './chart/ChartLoggerService.js';
|
||||
import FederationChart from './chart/charts/federation.js';
|
||||
import NotesChart from './chart/charts/notes.js';
|
||||
@ -187,6 +188,7 @@ const $UtilityService: Provider = { provide: 'UtilityService', useExisting: Util
|
||||
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
|
||||
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
|
||||
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
|
||||
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
||||
|
||||
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
||||
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
||||
@ -318,6 +320,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
FileInfoService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
ChartLoggerService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
@ -442,6 +445,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$FileInfoService,
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
$ChartLoggerService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
@ -567,6 +571,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
FileInfoService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
UsersChart,
|
||||
@ -690,6 +695,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$FileInfoService,
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
$UsersChart,
|
||||
|
@ -48,7 +48,6 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value.values())),
|
||||
fromRedisConverter: (value) => {
|
||||
if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す)
|
||||
return new Map(JSON.parse(value).map((x: Serialized<MiEmoji>) => [x.name, {
|
||||
...x,
|
||||
updatedAt: x.updatedAt ? new Date(x.updatedAt) : null,
|
||||
@ -380,6 +379,20 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ローカル内の絵文字に重複がないかチェックします
|
||||
* @param name 絵文字名
|
||||
*/
|
||||
@bindThis
|
||||
public checkDuplicate(name: string): Promise<boolean> {
|
||||
return this.emojisRepository.exist({ where: { name, host: IsNull() } });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getEmojiById(id: string): Promise<MiEmoji | null> {
|
||||
return this.emojisRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.cache.dispose();
|
||||
|
116
packages/backend/src/core/FeaturedService.ts
Normal file
116
packages/backend/src/core/FeaturedService.ts
Normal file
@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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 type { MiNote, MiUser } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
|
||||
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
|
||||
|
||||
@Injectable()
|
||||
export class FeaturedService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private getCurrentWindow(windowRange: number): number {
|
||||
const passed = new Date().getTime() - new Date(new Date().getFullYear(), 0, 1).getTime();
|
||||
return Math.floor(passed / windowRange);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise<void> {
|
||||
const currentWindow = this.getCurrentWindow(windowRange);
|
||||
const redisTransaction = this.redisClient.multi();
|
||||
redisTransaction.zincrby(
|
||||
`${name}:${currentWindow}`,
|
||||
score,
|
||||
element);
|
||||
redisTransaction.expire(
|
||||
`${name}:${currentWindow}`,
|
||||
(windowRange * 3) / 1000,
|
||||
'NX'); // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||
await redisTransaction.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getRankingOf(name: string, windowRange: number, limit: number): Promise<string[]> {
|
||||
const currentWindow = this.getCurrentWindow(windowRange);
|
||||
const previousWindow = currentWindow - 1;
|
||||
|
||||
const [currentRankingResult, previousRankingResult] = await Promise.all([
|
||||
this.redisClient.zrange(
|
||||
`${name}:${currentWindow}`, 0, limit, 'REV', 'WITHSCORES'),
|
||||
this.redisClient.zrange(
|
||||
`${name}:${previousWindow}`, 0, limit, 'REV', 'WITHSCORES'),
|
||||
]);
|
||||
|
||||
const ranking = new Map<string, number>();
|
||||
for (let i = 0; i < currentRankingResult.length; i += 2) {
|
||||
const noteId = currentRankingResult[i];
|
||||
const score = parseInt(currentRankingResult[i + 1], 10);
|
||||
ranking.set(noteId, score);
|
||||
}
|
||||
for (let i = 0; i < previousRankingResult.length; i += 2) {
|
||||
const noteId = previousRankingResult[i];
|
||||
const score = parseInt(previousRankingResult[i + 1], 10);
|
||||
const exist = ranking.get(noteId);
|
||||
if (exist != null) {
|
||||
ranking.set(noteId, (exist + score) / 2);
|
||||
} else {
|
||||
ranking.set(noteId, score);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ranking.keys());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getInChannelNotesRanking(channelId: MiNote['channelId'], limit: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, limit);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getPerUserNotesRanking(userId: MiUser['id'], limit: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, limit);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getHashtagsRanking(limit: number): Promise<string[]> {
|
||||
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, limit);
|
||||
}
|
||||
}
|
@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { getLineAndCharacterOfPosition } from 'typescript';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
@ -12,14 +14,19 @@ import type { MiHashtag } from '@/models/Hashtag.js';
|
||||
import type { HashtagsRepository } from '@/models/_.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
|
||||
@Injectable()
|
||||
export class HashtagService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||
|
||||
@Inject(DI.hashtagsRepository)
|
||||
private hashtagsRepository: HashtagsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private featuredService: FeaturedService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
@ -46,6 +53,9 @@ export class HashtagService {
|
||||
public async updateHashtag(user: { id: MiUser['id']; host: MiUser['host']; }, tag: string, isUserAttached = false, inc = true) {
|
||||
tag = normalizeForSearch(tag);
|
||||
|
||||
// TODO: サンプリング
|
||||
this.updateHashtagsRanking(tag, user.id);
|
||||
|
||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||
|
||||
if (index == null && !inc) return;
|
||||
@ -85,7 +95,7 @@ export class HashtagService {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
if (!index.mentionedUserIds.some(id => id === user.id)) {
|
||||
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
|
||||
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
|
||||
@ -144,4 +154,93 @@ export class HashtagService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateHashtagsRanking(hashtag: string, userId: MiUser['id']): Promise<void> {
|
||||
// TODO: instance.hiddenTagsの考慮
|
||||
|
||||
// YYYYMMDDHHmm (10分間隔)
|
||||
const now = new Date();
|
||||
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 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')}`;
|
||||
|
||||
const exist = await this.redisClient.sismember(`hashtagUsers:${hashtag}`, userId);
|
||||
if (exist === 1) return;
|
||||
|
||||
this.featuredService.updateHashtagsRanking(hashtag, 1);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
// TODO: これらの Set は Bloom Filter を使うようにしても良さそう
|
||||
|
||||
// チャート用
|
||||
redisPipeline.sadd(`hashtagUsers:${hashtag}:${window}`, userId);
|
||||
redisPipeline.expire(`hashtagUsers:${hashtag}:${window}`,
|
||||
60 * 60 * 24 * 3, // 3日間
|
||||
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||
);
|
||||
|
||||
// ユニークカウント用
|
||||
redisPipeline.sadd(`hashtagUsers:${hashtag}`, userId);
|
||||
redisPipeline.expire(`hashtagUsers:${hashtag}`,
|
||||
60 * 60, // 1時間
|
||||
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||
);
|
||||
|
||||
redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getChart(hashtag: string, range: number): Promise<number[]> {
|
||||
const now = new Date();
|
||||
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
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')}`;
|
||||
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`);
|
||||
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||
}
|
||||
|
||||
const result = await redisPipeline.exec();
|
||||
|
||||
if (result == null) return [];
|
||||
|
||||
return result.map(x => x[1]) as number[];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getCharts(hashtags: string[], range: number): Promise<Record<string, number[]>> {
|
||||
const now = new Date();
|
||||
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
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')}`;
|
||||
for (const hashtag of hashtags) {
|
||||
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`);
|
||||
}
|
||||
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||
}
|
||||
|
||||
const result = await redisPipeline.exec();
|
||||
|
||||
if (result == null) return {};
|
||||
|
||||
// key is hashtag
|
||||
const charts = {} as Record<string, number[]>;
|
||||
for (const hashtag of hashtags) {
|
||||
charts[hashtag] = [];
|
||||
}
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
for (let j = 0; j < hashtags.length; j++) {
|
||||
charts[hashtags[j]].push(result[(i * hashtags.length) + j][1] as number);
|
||||
}
|
||||
}
|
||||
|
||||
return charts;
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +53,7 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
@ -200,6 +201,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
private hashtagService: HashtagService,
|
||||
private antennaService: AntennaService,
|
||||
private webhookService: WebhookService,
|
||||
private featuredService: FeaturedService,
|
||||
private remoteUserResolveService: RemoteUserResolveService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private apRendererService: ApRendererService,
|
||||
@ -252,19 +254,30 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
if (data.renote) {
|
||||
switch (data.renote.visibility) {
|
||||
case 'public':
|
||||
// public noteは無条件にrenote可能
|
||||
break;
|
||||
case 'home':
|
||||
// home noteはhome以下にrenote可能
|
||||
if (data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
break;
|
||||
case 'followers':
|
||||
// 他人のfollowers noteはreject
|
||||
if (data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
|
||||
// Renote対象がpublicではないならhomeにする
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
if (data.renote && data.renote.visibility === 'followers') {
|
||||
data.visibility = 'followers';
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
data.visibility = 'followers';
|
||||
break;
|
||||
case 'specified':
|
||||
// specified / direct noteはreject
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
}
|
||||
|
||||
// 返信対象がpublicではないならhomeにする
|
||||
@ -508,9 +521,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
});
|
||||
}
|
||||
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
|
||||
if (!user.isBot) this.incRenoteCount(data.renote);
|
||||
if (data.renote && data.renote.userId !== user.id && !user.isBot) {
|
||||
this.incRenoteCount(data.renote);
|
||||
}
|
||||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
@ -710,10 +722,23 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
renoteCount: () => '"renoteCount" + 1',
|
||||
score: () => '"score" + 1',
|
||||
})
|
||||
.where('id = :id', { id: renote.id })
|
||||
.execute();
|
||||
|
||||
// 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||
if (renote.channelId != null) {
|
||||
if (renote.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5);
|
||||
}
|
||||
} else {
|
||||
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(renote.id, 5);
|
||||
this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -806,6 +831,12 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
const redisPipeline = this.redisForTimelines.pipeline();
|
||||
|
||||
if (note.channelId) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithChannel:${user.id}`,
|
||||
'MAXLEN', '~', note.userHost == null ? meta.perLocalUserUserTimelineCacheMax.toString() : meta.perRemoteUserUserTimelineCacheMax.toString(),
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
const channelFollowings = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followeeId: note.channelId,
|
||||
|
@ -64,12 +64,6 @@ export class NoteDeleteService {
|
||||
const deletedAt = new Date();
|
||||
const cascadingNotes = await this.findCascadingNotes(note);
|
||||
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
|
||||
this.notesRepository.decrement({ id: note.renoteId }, 'renoteCount', 1);
|
||||
if (!user.isBot) this.notesRepository.decrement({ id: note.renoteId }, 'score', 1);
|
||||
}
|
||||
|
||||
if (note.replyId) {
|
||||
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
|
||||
}
|
||||
|
@ -96,6 +96,8 @@ export class PollService {
|
||||
const note = await this.notesRepository.findOneBy({ id: noteId });
|
||||
if (note == null) throw new Error('note not found');
|
||||
|
||||
if (note.localOnly) return;
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
if (user == null) throw new Error('note not found');
|
||||
|
||||
|
@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
@ -26,6 +27,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
|
||||
const FALLBACK = '❤';
|
||||
|
||||
@ -66,6 +68,9 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
|
||||
@Injectable()
|
||||
export class ReactionService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@ -86,6 +91,7 @@ export class ReactionService {
|
||||
private noteEntityService: NoteEntityService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
private idService: IdService,
|
||||
private featuredService: FeaturedService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
@ -182,11 +188,28 @@ export class ReactionService {
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
... (!user.isBot ? { score: () => '"score" + 1' } : {}),
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
|
||||
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (
|
||||
Math.random() < 0.3 &&
|
||||
note.userId !== user.id &&
|
||||
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
|
||||
) {
|
||||
if (note.channelId != null) {
|
||||
if (note.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1);
|
||||
}
|
||||
} else {
|
||||
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(note.id, 1);
|
||||
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
@ -275,8 +298,6 @@ export class ReactionService {
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
|
||||
if (!user.isBot) this.notesRepository.decrement({ id: note.id }, 'score', 1);
|
||||
|
||||
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
|
||||
reaction: this.decodeReaction(exist.reaction).reaction,
|
||||
userId: user.id,
|
||||
|
@ -450,19 +450,4 @@ export class NoteEntityService implements OnModuleInit {
|
||||
}
|
||||
return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise<number> {
|
||||
// 指定したユーザーの指定したノートのリノートがいくつあるか数える
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.userId = :userId', { userId })
|
||||
.andWhere('note.renoteId = :renoteId', { renoteId });
|
||||
|
||||
// 指定した投稿を除く
|
||||
if (excludeNoteId) {
|
||||
query.andWhere('note.id != :excludeNoteId', { excludeNoteId });
|
||||
}
|
||||
|
||||
return await query.getCount();
|
||||
}
|
||||
}
|
||||
|
@ -146,64 +146,76 @@ export class UserEntityService implements OnModuleInit {
|
||||
|
||||
@bindThis
|
||||
public async getRelation(me: MiUser['id'], target: MiUser['id']) {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: me,
|
||||
followeeId: target,
|
||||
});
|
||||
return awaitAll({
|
||||
id: target,
|
||||
const [
|
||||
following,
|
||||
isFollowing: following != null,
|
||||
isFollowed: this.followingsRepository.count({
|
||||
isFollowed,
|
||||
hasPendingFollowRequestFromYou,
|
||||
hasPendingFollowRequestToYou,
|
||||
isBlocking,
|
||||
isBlocked,
|
||||
isMuted,
|
||||
isRenoteMuted,
|
||||
] = await Promise.all([
|
||||
this.followingsRepository.findOneBy({
|
||||
followerId: me,
|
||||
followeeId: target,
|
||||
}),
|
||||
this.followingsRepository.exist({
|
||||
where: {
|
||||
followerId: target,
|
||||
followeeId: me,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
hasPendingFollowRequestFromYou: this.followRequestsRepository.count({
|
||||
}),
|
||||
this.followRequestsRepository.exist({
|
||||
where: {
|
||||
followerId: me,
|
||||
followeeId: target,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
hasPendingFollowRequestToYou: this.followRequestsRepository.count({
|
||||
}),
|
||||
this.followRequestsRepository.exist({
|
||||
where: {
|
||||
followerId: target,
|
||||
followeeId: me,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
isBlocking: this.blockingsRepository.count({
|
||||
}),
|
||||
this.blockingsRepository.exist({
|
||||
where: {
|
||||
blockerId: me,
|
||||
blockeeId: target,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
isBlocked: this.blockingsRepository.count({
|
||||
}),
|
||||
this.blockingsRepository.exist({
|
||||
where: {
|
||||
blockerId: target,
|
||||
blockeeId: me,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
isMuted: this.mutingsRepository.count({
|
||||
}),
|
||||
this.mutingsRepository.exist({
|
||||
where: {
|
||||
muterId: me,
|
||||
muteeId: target,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
isRenoteMuted: this.renoteMutingsRepository.count({
|
||||
}),
|
||||
this.renoteMutingsRepository.exist({
|
||||
where: {
|
||||
muterId: me,
|
||||
muteeId: target,
|
||||
},
|
||||
take: 1,
|
||||
}).then(n => n > 0),
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: target,
|
||||
following,
|
||||
isFollowing: following != null,
|
||||
isFollowed,
|
||||
hasPendingFollowRequestFromYou,
|
||||
hasPendingFollowRequestToYou,
|
||||
isBlocking,
|
||||
isBlocked,
|
||||
isMuted,
|
||||
isRenoteMuted,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -290,24 +302,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
|
||||
const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src });
|
||||
|
||||
// migration
|
||||
if (user.avatarId != null && user.avatarUrl === null) {
|
||||
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
|
||||
user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
|
||||
this.usersRepository.update(user.id, {
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarBlurhash: avatar.blurhash,
|
||||
});
|
||||
}
|
||||
if (user.bannerId != null && user.bannerUrl === null) {
|
||||
const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
|
||||
user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
|
||||
this.usersRepository.update(user.id, {
|
||||
bannerUrl: user.bannerUrl,
|
||||
bannerBlurhash: banner.blurhash,
|
||||
});
|
||||
}
|
||||
|
||||
const meId = me ? me.id : null;
|
||||
const isMe = meId === user.id;
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
|
@ -138,11 +138,6 @@ export class MiNote {
|
||||
})
|
||||
public url: string | null;
|
||||
|
||||
@Column('integer', {
|
||||
default: 0, select: false,
|
||||
})
|
||||
public score: number;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
|
@ -14,7 +14,6 @@ export class MiNoteReaction {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the NoteReaction.',
|
||||
})
|
||||
|
@ -325,6 +325,7 @@ import * as ep___users_followers from './endpoints/users/followers.js';
|
||||
import * as ep___users_following from './endpoints/users/following.js';
|
||||
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
|
||||
import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
|
||||
import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
|
||||
import * as ep___users_lists_create from './endpoints/users/lists/create.js';
|
||||
import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
|
||||
import * as ep___users_lists_list from './endpoints/users/lists/list.js';
|
||||
@ -674,6 +675,7 @@ const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep
|
||||
const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default };
|
||||
const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default };
|
||||
const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default };
|
||||
const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default };
|
||||
const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default };
|
||||
const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default };
|
||||
const $users_lists_list: Provider = { provide: 'ep:users/lists/list', useClass: ep___users_lists_list.default };
|
||||
@ -1027,6 +1029,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$users_following,
|
||||
$users_gallery_posts,
|
||||
$users_getFrequentlyRepliedUsers,
|
||||
$users_featuredNotes,
|
||||
$users_lists_create,
|
||||
$users_lists_delete,
|
||||
$users_lists_list,
|
||||
@ -1371,6 +1374,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$users_following,
|
||||
$users_gallery_posts,
|
||||
$users_getFrequentlyRepliedUsers,
|
||||
$users_featuredNotes,
|
||||
$users_lists_create,
|
||||
$users_lists_delete,
|
||||
$users_lists_list,
|
||||
|
@ -325,6 +325,7 @@ import * as ep___users_followers from './endpoints/users/followers.js';
|
||||
import * as ep___users_following from './endpoints/users/following.js';
|
||||
import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
|
||||
import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
|
||||
import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
|
||||
import * as ep___users_lists_create from './endpoints/users/lists/create.js';
|
||||
import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
|
||||
import * as ep___users_lists_list from './endpoints/users/lists/list.js';
|
||||
@ -672,6 +673,7 @@ const eps = [
|
||||
['users/following', ep___users_following],
|
||||
['users/gallery/posts', ep___users_gallery_posts],
|
||||
['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers],
|
||||
['users/featured-notes', ep___users_featuredNotes],
|
||||
['users/lists/create', ep___users_lists_create],
|
||||
['users/lists/delete', ep___users_lists_delete],
|
||||
['users/lists/list', ep___users_lists_list],
|
||||
|
@ -23,6 +23,11 @@ export const meta = {
|
||||
code: 'NO_SUCH_FILE',
|
||||
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
|
||||
},
|
||||
duplicateName: {
|
||||
message: 'Duplicate name.',
|
||||
code: 'DUPLICATE_NAME',
|
||||
id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -64,6 +69,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
|
||||
if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
|
||||
|
||||
const emoji = await this.customEmojiService.add({
|
||||
driveFile,
|
||||
|
@ -74,6 +74,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
const emoji = await this.customEmojiService.getEmojiById(ps.id);
|
||||
if (emoji != null) {
|
||||
if (ps.name !== emoji.name) {
|
||||
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
|
||||
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
|
||||
}
|
||||
} else {
|
||||
throw new ApiError(meta.errors.noSuchEmoji);
|
||||
}
|
||||
|
||||
await this.customEmojiService.update(ps.id, {
|
||||
driveFile,
|
||||
|
@ -85,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
isModerator: isModerator,
|
||||
isSilenced: isSilenced,
|
||||
isSuspended: user.isSuspended,
|
||||
isHibernated: user.isHibernated,
|
||||
lastActiveDate: user.lastActiveDate,
|
||||
moderationNote: profile.moderationNote ?? '',
|
||||
signins,
|
||||
|
@ -79,14 +79,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
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);
|
||||
}
|
||||
|
||||
@ -110,7 +110,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
timeline = await query.limit(ps.limit).getMany();
|
||||
} else {
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
|
@ -3,29 +3,13 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { safeForSql } from '@/misc/safe-for-sql.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
/*
|
||||
トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要
|
||||
ユニーク投稿数とはそのハッシュタグと投稿ユーザーのペアのカウントで、例えば同じユーザーが複数回同じハッシュタグを投稿してもそのハッシュタグのユニーク投稿数は1とカウントされる
|
||||
|
||||
..が理想だけどPostgreSQLでどうするのか分からないので単に「直近Aの内に投稿されたユニーク投稿数が多いハッシュタグ」で妥協する
|
||||
*/
|
||||
|
||||
const rangeA = 1000 * 60 * 60; // 60分
|
||||
//const rangeB = 1000 * 60 * 120; // 2時間
|
||||
//const coefficient = 1.25; // 「n倍」の部分
|
||||
//const requiredUsers = 3; // 最低何人がそのタグを投稿している必要があるか
|
||||
|
||||
const max = 5;
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { HashtagService } from '@/core/HashtagService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['hashtags'],
|
||||
@ -71,98 +55,22 @@ export const paramDef = {
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private featuredService: FeaturedService,
|
||||
private hashtagService: HashtagService,
|
||||
) {
|
||||
super(meta, paramDef, async () => {
|
||||
const instance = await this.metaService.fetch(true);
|
||||
const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t));
|
||||
|
||||
const now = new Date(); // 5分単位で丸めた現在日時
|
||||
now.setMinutes(Math.round(now.getMinutes() / 5) * 5, 0, 0);
|
||||
const ranking = await this.featuredService.getHashtagsRanking(10);
|
||||
|
||||
const tagNotes = await this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.createdAt > :date', { date: new Date(now.getTime() - rangeA) })
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
.andWhere('note.tags != \'{}\'')
|
||||
.select(['note.tags', 'note.userId'])
|
||||
.cache(60000) // 1 min
|
||||
.getMany();
|
||||
const charts = ranking.length === 0 ? {} : await this.hashtagService.getCharts(ranking, 20);
|
||||
|
||||
if (tagNotes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tags: {
|
||||
name: string;
|
||||
users: MiNote['userId'][];
|
||||
}[] = [];
|
||||
|
||||
for (const note of tagNotes) {
|
||||
for (const tag of note.tags) {
|
||||
if (hiddenTags.includes(tag)) continue;
|
||||
|
||||
const x = tags.find(x => x.name === tag);
|
||||
if (x) {
|
||||
if (!x.users.includes(note.userId)) {
|
||||
x.users.push(note.userId);
|
||||
}
|
||||
} else {
|
||||
tags.push({
|
||||
name: tag,
|
||||
users: [note.userId],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// タグを人気順に並べ替え
|
||||
const hots = tags
|
||||
.sort((a, b) => b.users.length - a.users.length)
|
||||
.map(tag => tag.name)
|
||||
.slice(0, max);
|
||||
|
||||
//#region 2(または3)で話題と判定されたタグそれぞれについて過去の投稿数グラフを取得する
|
||||
const countPromises: Promise<number[]>[] = [];
|
||||
|
||||
const range = 20;
|
||||
|
||||
// 10分
|
||||
const interval = 1000 * 60 * 10;
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
countPromises.push(Promise.all(hots.map(tag => this.notesRepository.createQueryBuilder('note')
|
||||
.select('count(distinct note.userId)')
|
||||
.where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`)
|
||||
.andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) })
|
||||
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) })
|
||||
.cache(60000) // 1 min
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
)));
|
||||
}
|
||||
|
||||
const countsLog = await Promise.all(countPromises);
|
||||
//#endregion
|
||||
|
||||
const totalCounts = await Promise.all(hots.map(tag => this.notesRepository.createQueryBuilder('note')
|
||||
.select('count(distinct note.userId)')
|
||||
.where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`)
|
||||
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) })
|
||||
.cache(60000 * 60) // 60 min
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
));
|
||||
|
||||
const stats = hots.map((tag, i) => ({
|
||||
const stats = ranking.map((tag, i) => ({
|
||||
tag,
|
||||
chart: countsLog.map(counts => counts[i]),
|
||||
usersCount: totalCounts[i],
|
||||
chart: charts[tag],
|
||||
usersCount: Math.max(...charts[tag]),
|
||||
}));
|
||||
|
||||
return stats;
|
||||
|
@ -57,6 +57,12 @@ export const meta = {
|
||||
id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
|
||||
},
|
||||
|
||||
cannotRenoteDueToVisibility: {
|
||||
message: 'You can not Renote due to target visibility.',
|
||||
code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
|
||||
id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
|
||||
},
|
||||
|
||||
noSuchReplyTarget: {
|
||||
message: 'No such reply target.',
|
||||
code: 'NO_SUCH_REPLY_TARGET',
|
||||
@ -231,6 +237,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
throw new ApiError(meta.errors.youHaveBeenBlocked);
|
||||
}
|
||||
}
|
||||
|
||||
if (renote.visibility === 'followers' && renote.userId !== me.id) {
|
||||
// 他人のfollowers noteはreject
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
} else if (renote.visibility === 'specified') {
|
||||
// specified / direct noteはreject
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
}
|
||||
}
|
||||
|
||||
let reply: MiNote | null = null;
|
||||
|
@ -6,9 +6,9 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
@ -32,7 +32,7 @@ export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
channelId: { type: 'string', nullable: true, format: 'misskey:id' },
|
||||
},
|
||||
required: [],
|
||||
@ -40,41 +40,53 @@ export const paramDef = {
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
private globalNotesRankingCache: string[] = [];
|
||||
private globalNotesRankingCacheLastFetchedAt = 0;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private featuredService: FeaturedService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで
|
||||
let noteIds: string[];
|
||||
if (ps.channelId) {
|
||||
noteIds = await this.featuredService.getInChannelNotesRanking(ps.channelId, 50);
|
||||
} else {
|
||||
if (this.globalNotesRankingCacheLastFetchedAt !== 0 && (Date.now() - this.globalNotesRankingCacheLastFetchedAt < 1000 * 60 * 30)) {
|
||||
noteIds = this.globalNotesRankingCache;
|
||||
} else {
|
||||
noteIds = await this.featuredService.getGlobalNotesRanking(100);
|
||||
this.globalNotesRankingCache = noteIds;
|
||||
this.globalNotesRankingCacheLastFetchedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||
if (ps.untilId) {
|
||||
noteIds = noteIds.filter(id => id < ps.untilId!);
|
||||
}
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.addSelect('note.score')
|
||||
.where('note.userHost IS NULL')
|
||||
.andWhere('note.score > 0')
|
||||
.andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) })
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
|
||||
const notes = await query.getMany();
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
||||
|
||||
let notes = await query
|
||||
.orderBy('note.score', 'DESC')
|
||||
.limit(100)
|
||||
.getMany();
|
||||
|
||||
notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
notes = notes.slice(ps.offset, ps.offset + ps.limit);
|
||||
// TODO: ミュート等考慮
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
|
@ -91,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
let htlNoteIdsRes: [string, string[]][] = [];
|
||||
let ltlNoteIdsRes: [string, string[]][] = [];
|
||||
|
||||
@ -100,18 +100,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
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);
|
||||
const ltlNoteIds = ltlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
|
||||
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]));
|
||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
@ -87,18 +87,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
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);
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
|
@ -78,18 +78,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
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);
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
|
@ -102,18 +102,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
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);
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
|
@ -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 type { NotesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
requireCredential: false,
|
||||
allowGet: true,
|
||||
cacheSec: 3600,
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Note',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['userId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private featuredService: FeaturedService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||
if (ps.untilId) {
|
||||
noteIds = noteIds.filter(id => id < ps.untilId!);
|
||||
}
|
||||
noteIds = noteIds.slice(0, ps.limit);
|
||||
|
||||
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');
|
||||
|
||||
const notes = await query.getMany();
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
// TODO: ミュート等考慮
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
}
|
||||
}
|
@ -44,6 +44,7 @@ export const paramDef = {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
withReplies: { type: 'boolean', default: false },
|
||||
withRenotes: { type: 'boolean', default: true },
|
||||
withChannelNotes: { type: 'boolean', default: false },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
@ -79,30 +80,39 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
let timeline: MiNote[] = [];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
let noteIdsRes: [string, string[]][] = [];
|
||||
let repliesNoteIdsRes: [string, string[]][] = [];
|
||||
let channelNoteIdsRes: [string, string[]][] = [];
|
||||
|
||||
if (!ps.sinceId && !ps.sinceDate) {
|
||||
[noteIdsRes, repliesNoteIdsRes] = await Promise.all([
|
||||
[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.map(x => x[1][1]).filter(x => x !== ps.untilId),
|
||||
...repliesNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId),
|
||||
...noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
|
||||
...repliesNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
|
||||
...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);
|
||||
|
@ -363,6 +363,21 @@ describe('Timelines', () => {
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false);
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false);
|
||||
}, 1000 * 10);
|
||||
|
||||
test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body);
|
||||
await api('/following/create', { userId: bob.id }, alice);
|
||||
await sleep(1000);
|
||||
const bobNote = await post(bob, { text: 'hi', channelId: channel.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', () => {
|
||||
@ -380,6 +395,19 @@ describe('Timelines', () => {
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('チャンネル投稿が含まれない', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body);
|
||||
const bobNote = await post(bob, { text: 'hi', channelId: channel.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/notes/local-timeline', {}, alice);
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('リモートユーザーのノートが含まれない', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]);
|
||||
|
||||
@ -718,6 +746,22 @@ describe('Timelines', () => {
|
||||
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
|
||||
});
|
||||
|
||||
test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body);
|
||||
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', channelId: channel.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);
|
||||
});
|
||||
|
||||
test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
@ -776,6 +820,19 @@ describe('Timelines', () => {
|
||||
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
|
||||
});
|
||||
|
||||
test.concurrent('チャンネル投稿が含まれない', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body);
|
||||
const bobNote = await post(bob, { text: 'hi', channelId: channel.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/users/notes', { userId: bob.id }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('[withReplies: false] 他人への返信が含まれない', async () => {
|
||||
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
|
||||
|
||||
@ -836,6 +893,19 @@ describe('Timelines', () => {
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
|
||||
}, 1000 * 10);
|
||||
|
||||
test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
const channel = await api('/channels/create', { name: 'channel' }, bob).then(x => x.body);
|
||||
const bobNote = await post(bob, { text: 'hi', channelId: channel.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('/users/notes', { userId: bob.id, withChannelNotes: true }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
|
||||
});
|
||||
|
||||
test.concurrent('ミュートしているユーザーに関連する投稿が含まれない', async () => {
|
||||
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
|
||||
|
||||
|
@ -301,12 +301,14 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
|
||||
};
|
||||
|
||||
export const uploadUrl = async (user: UserToken, url: string) => {
|
||||
let file: any;
|
||||
let resolve: unknown;
|
||||
const file = new Promise(ok => resolve = ok);
|
||||
const marker = Math.random().toString();
|
||||
|
||||
const ws = await connectStream(user, 'main', (msg) => {
|
||||
if (msg.type === 'urlUploadFinished' && msg.body.marker === marker) {
|
||||
file = msg.body.file;
|
||||
ws.close();
|
||||
resolve(msg.body.file);
|
||||
}
|
||||
});
|
||||
|
||||
@ -316,9 +318,6 @@ export const uploadUrl = async (user: UserToken, url: string) => {
|
||||
force: true,
|
||||
}, user);
|
||||
|
||||
await sleep(7000);
|
||||
ws.close();
|
||||
|
||||
return file;
|
||||
};
|
||||
|
||||
|
@ -18,13 +18,13 @@
|
||||
"dependencies": {
|
||||
"@discordapp/twemoji": "14.1.2",
|
||||
"@github/webauthn-json": "2.1.1",
|
||||
"@rollup/plugin-alias": "5.0.0",
|
||||
"@rollup/plugin-json": "6.0.0",
|
||||
"@rollup/plugin-replace": "5.0.2",
|
||||
"@rollup/pluginutils": "5.0.4",
|
||||
"@rollup/plugin-alias": "5.0.1",
|
||||
"@rollup/plugin-json": "6.0.1",
|
||||
"@rollup/plugin-replace": "5.0.3",
|
||||
"@rollup/pluginutils": "5.0.5",
|
||||
"@syuilo/aiscript": "0.16.0",
|
||||
"@tabler/icons-webfont": "2.37.0",
|
||||
"@vitejs/plugin-vue": "4.3.4",
|
||||
"@vitejs/plugin-vue": "4.4.0",
|
||||
"@vue-macros/reactivity-transform": "0.3.23",
|
||||
"@vue/compiler-sfc": "3.3.4",
|
||||
"astring": "1.8.6",
|
||||
@ -38,7 +38,7 @@
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "7.2.0",
|
||||
"chromatic": "7.2.2",
|
||||
"compare-versions": "6.1.0",
|
||||
"cropperjs": "2.0.0-beta.4",
|
||||
"date-fns": "2.30.0",
|
||||
@ -53,13 +53,13 @@
|
||||
"matter-js": "0.19.0",
|
||||
"mfm-js": "0.23.3",
|
||||
"misskey-js": "workspace:*",
|
||||
"photoswipe": "5.4.1",
|
||||
"photoswipe": "5.4.2",
|
||||
"prismjs": "1.29.0",
|
||||
"punycode": "2.3.0",
|
||||
"querystring": "0.2.1",
|
||||
"rollup": "3.29.4",
|
||||
"rollup": "4.0.0",
|
||||
"sanitize-html": "2.11.0",
|
||||
"sass": "1.68.0",
|
||||
"sass": "1.69.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.157.0",
|
||||
@ -72,36 +72,36 @@
|
||||
"uuid": "9.0.1",
|
||||
"v-code-diff": "1.7.1",
|
||||
"vanilla-tilt": "1.8.1",
|
||||
"vite": "4.4.9",
|
||||
"vite": "4.4.11",
|
||||
"vue": "3.3.4",
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "7.4.5",
|
||||
"@storybook/addon-essentials": "7.4.5",
|
||||
"@storybook/addon-interactions": "7.4.5",
|
||||
"@storybook/addon-links": "7.4.5",
|
||||
"@storybook/addon-storysource": "7.4.5",
|
||||
"@storybook/addons": "7.4.5",
|
||||
"@storybook/blocks": "7.4.5",
|
||||
"@storybook/core-events": "7.4.5",
|
||||
"@storybook/jest": "0.2.2",
|
||||
"@storybook/manager-api": "7.4.5",
|
||||
"@storybook/preview-api": "7.4.5",
|
||||
"@storybook/react": "7.4.5",
|
||||
"@storybook/react-vite": "7.4.5",
|
||||
"@storybook/testing-library": "0.2.1",
|
||||
"@storybook/theming": "7.4.5",
|
||||
"@storybook/types": "7.4.5",
|
||||
"@storybook/vue3": "7.4.5",
|
||||
"@storybook/vue3-vite": "7.4.5",
|
||||
"@storybook/addon-actions": "7.4.6",
|
||||
"@storybook/addon-essentials": "7.4.6",
|
||||
"@storybook/addon-interactions": "7.4.6",
|
||||
"@storybook/addon-links": "7.4.6",
|
||||
"@storybook/addon-storysource": "7.4.6",
|
||||
"@storybook/addons": "7.4.6",
|
||||
"@storybook/blocks": "7.4.6",
|
||||
"@storybook/core-events": "7.4.6",
|
||||
"@storybook/jest": "0.2.3",
|
||||
"@storybook/manager-api": "7.4.6",
|
||||
"@storybook/preview-api": "7.4.6",
|
||||
"@storybook/react": "7.4.6",
|
||||
"@storybook/react-vite": "7.4.6",
|
||||
"@storybook/testing-library": "0.2.2",
|
||||
"@storybook/theming": "7.4.6",
|
||||
"@storybook/types": "7.4.6",
|
||||
"@storybook/vue3": "7.4.6",
|
||||
"@storybook/vue3-vite": "7.4.6",
|
||||
"@testing-library/vue": "7.0.0",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/estree": "1.0.2",
|
||||
"@types/matter-js": "0.19.1",
|
||||
"@types/micromatch": "4.0.3",
|
||||
"@types/node": "20.7.1",
|
||||
"@types/node": "20.8.2",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/sanitize-html": "2.9.1",
|
||||
"@types/throttle-debounce": "5.0.0",
|
||||
@ -109,9 +109,9 @@
|
||||
"@types/uuid": "9.0.4",
|
||||
"@types/websocket": "1.0.7",
|
||||
"@types/ws": "8.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.3",
|
||||
"@typescript-eslint/parser": "6.7.3",
|
||||
"@vitest/coverage-v8": "0.34.5",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"@vitest/coverage-v8": "0.34.6",
|
||||
"@vue/runtime-core": "3.3.4",
|
||||
"acorn": "8.10.0",
|
||||
"cross-env": "7.0.3",
|
||||
@ -122,18 +122,18 @@
|
||||
"fast-glob": "3.3.1",
|
||||
"happy-dom": "10.0.3",
|
||||
"micromatch": "4.0.5",
|
||||
"msw": "1.3.1",
|
||||
"msw": "1.3.2",
|
||||
"msw-storybook-addon": "1.8.0",
|
||||
"nodemon": "3.0.1",
|
||||
"prettier": "3.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"start-server-and-test": "2.0.1",
|
||||
"storybook": "7.4.5",
|
||||
"storybook": "7.4.6",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "0.34.5",
|
||||
"vitest": "0.34.6",
|
||||
"vitest-fetch-mock": "0.2.2",
|
||||
"vue-eslint-parser": "9.3.1",
|
||||
"vue-tsc": "1.8.15"
|
||||
|
@ -215,7 +215,7 @@ const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
|
||||
const translation = ref<any>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id));
|
||||
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null)));
|
||||
|
||||
const keymap = {
|
||||
|
@ -102,7 +102,6 @@ let searchKey = $ref('');
|
||||
const featuredPagination = $computed(() => ({
|
||||
endpoint: 'notes/featured' as const,
|
||||
limit: 10,
|
||||
offsetMode: true,
|
||||
params: {
|
||||
channelId: props.channelId,
|
||||
},
|
||||
|
@ -22,7 +22,6 @@ import { i18n } from '@/i18n.js';
|
||||
const paginationForNotes = {
|
||||
endpoint: 'notes/featured' as const,
|
||||
limit: 10,
|
||||
offsetMode: true,
|
||||
};
|
||||
|
||||
const paginationForPolls = {
|
||||
|
@ -131,7 +131,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<XFiles :key="user.id" :user="user"/>
|
||||
<XActivity :key="user.id" :user="user"/>
|
||||
</template>
|
||||
<MkNotes v-if="!disableNotes" :class="$style.tl" :noGap="true" :pagination="pagination"/>
|
||||
<div v-if="!disableNotes">
|
||||
<div style="margin-bottom: 8px;">{{ i18n.ts.featured }}</div>
|
||||
<MkNotes :class="$style.tl" :noGap="true" :pagination="pagination"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
|
||||
@ -210,7 +213,7 @@ watch($$(moderationNote), async () => {
|
||||
});
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'users/notes' as const,
|
||||
endpoint: 'users/featured-notes' as const,
|
||||
limit: 10,
|
||||
params: computed(() => ({
|
||||
userId: props.user.id,
|
||||
|
@ -29,7 +29,7 @@ const props = defineProps<{
|
||||
user: Misskey.entities.UserDetailed;
|
||||
}>();
|
||||
|
||||
const include = ref<string | null>(null);
|
||||
const include = ref<string | null>('all');
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'users/notes' as const,
|
||||
@ -38,6 +38,7 @@ const pagination = {
|
||||
userId: props.user.id,
|
||||
withRenotes: include.value === 'all',
|
||||
withReplies: include.value === 'all' || include.value === 'files',
|
||||
withChannelNotes: include.value === 'all',
|
||||
withFiles: include.value === 'files',
|
||||
})),
|
||||
};
|
||||
|
@ -20,12 +20,12 @@
|
||||
"url": "git+https://github.com/misskey-dev/misskey.js.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "7.37.2",
|
||||
"@microsoft/api-extractor": "7.38.0",
|
||||
"@swc/jest": "0.2.29",
|
||||
"@types/jest": "29.5.5",
|
||||
"@types/node": "20.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.3",
|
||||
"@typescript-eslint/parser": "6.7.3",
|
||||
"@types/node": "20.8.2",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"eslint": "8.50.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-fetch-mock": "3.0.3",
|
||||
@ -39,7 +39,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@swc/cli": "0.1.62",
|
||||
"@swc/core": "1.3.90",
|
||||
"@swc/core": "1.3.92",
|
||||
"eventemitter3": "5.0.1",
|
||||
"reconnecting-websocket": "4.4.0"
|
||||
}
|
||||
|
@ -14,7 +14,7 @@
|
||||
"misskey-js": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "6.7.3",
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
|
||||
"eslint": "8.50.0",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
|
1532
pnpm-lock.yaml
1532
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user