Compare commits

...

74 Commits

Author SHA1 Message Date
syuilo
f964ef163b Merge pull request #11963 from misskey-dev/develop
Release: 2023.10.0
2023-10-10 20:40:13 +09:00
syuilo
854ac95511 fix(backend): センシティブ設定されたチャンネルの投稿をusers/notesで返さないように 2023-10-10 20:06:02 +09:00
syuilo
51b6a012a5 fix(frontend): ユーザープロフィールページでセンシティブなメディアが隠されない問題を修正 2023-10-10 19:49:25 +09:00
syuilo
085bcf24da Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2023-10-10 19:08:02 +09:00
syuilo
66940d6cf1 fix(backend): channels/timelineでミュートが効かない問題を修正 2023-10-10 19:07:59 +09:00
syuilo
61ff98c8dd New Crowdin updates (#12000)
* New translations ja-jp.yml (French)

* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Spanish)

* New translations ja-jp.yml (Czech)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Polish)

* New translations ja-jp.yml (Russian)

* New translations ja-jp.yml (Slovak)

* New translations ja-jp.yml (Ukrainian)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Vietnamese)

* New translations ja-jp.yml (Indonesian)

* New translations ja-jp.yml (Bengali)

* New translations ja-jp.yml (Thai)

* New translations ja-jp.yml (Japanese, Kansai)
2023-10-10 19:03:21 +09:00
syuilo
43fe0cfda8 Update CHANGELOG.md 2023-10-10 19:03:01 +09:00
syuilo
57b794edfb New Crowdin updates (#11999)
* New translations ja-jp.yml (French)

* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (English)
2023-10-10 18:14:09 +09:00
syuilo
47de264478 2023.10.0 2023-10-10 18:13:57 +09:00
syuilo
373c2af46a clean up 2023-10-10 18:11:58 +09:00
syuilo
f5e72f7d3e 🎨 CWボタンを大きく 2023-10-10 18:08:54 +09:00
syuilo
d81c833775 Update CHANGELOG.md 2023-10-10 16:34:22 +09:00
syuilo
cf6e53b2ac update deps 2023-10-10 16:26:48 +09:00
syuilo
9dd0f8c39b clean up 2023-10-10 16:25:06 +09:00
syuilo
d94380780f Update CHANGELOG.md 2023-10-10 10:47:11 +09:00
かっこかり
af1087aed4 Feat:「ファイルの詳細」ページを追加 (#11995)
* (add) ファイルビューア

* Update Changelog

* 既存のAPIを利用

* run api extratctor

* Change i18n

* (add) ページに関する説明を追加

* Update CHANGELOG

* (fix) design, classes
2023-10-10 10:43:43 +09:00
syuilo
9f33ce1cd0 fix of 0bb0c32908 2023-10-10 09:45:40 +09:00
syuilo
4eb9e50a36 2023.10.0-beta.15 2023-10-09 21:52:43 +09:00
syuilo
8ab3640291 fix of 0bb0c32908 2023-10-09 21:52:31 +09:00
syuilo
fc777be7bc 2023.10.0-beta.14 2023-10-09 21:23:18 +09:00
syuilo
edf847d966 fix of 0bb0c32908 2023-10-09 21:23:07 +09:00
syuilo
457b880eba 2023.10.0-beta.13 2023-10-09 20:55:53 +09:00
syuilo
13dbfef9f8 update deps 2023-10-09 20:55:40 +09:00
syuilo
11c9e193a4 fix(backend): Misskeyのバックエンドプロセスが終了しない
Resolve #10995
2023-10-09 20:47:49 +09:00
syuilo
0bb0c32908 enhance(backend): RedisへのTLの構築をListで行うように
#11404
2023-10-09 20:31:39 +09:00
syuilo
aafe80c121 2023.10.0-beta.12 2023-10-09 18:48:43 +09:00
syuilo
7473b2854f fix(backend): users/notesでsinceId指定時にデータベースにフォールバックするように修正 2023-10-09 18:14:38 +09:00
syuilo
04971ca565 perf(backend): untilDate/sinceDate指定時のクエリパフォーマンスを向上 2023-10-09 18:13:53 +09:00
syuilo
6ff98846e6 fix(backend): 「ファイル付きのみ」のTLでファイル無しの新着ノートが表示される
Fix #11939
2023-10-09 17:48:09 +09:00
syuilo
7066d61730 fix 2023-10-09 17:41:54 +09:00
syuilo
b3d6334b5c 2023.10.0-beta.11 2023-10-09 16:56:51 +09:00
syuilo
0680ea3a78 fix(backend): users/notes で途中までしかページネーションできなくなることがある問題を修正 2023-10-09 16:54:27 +09:00
syuilo
0f367da84b fix(backend): TLを途中までしかページネーションできなくなることがある問題を修正
Fix #11404
2023-10-09 16:47:46 +09:00
syuilo
19a507633e lint fixes 2023-10-09 15:37:58 +09:00
syuilo
3a4039e2e1 refactor 2023-10-09 15:34:03 +09:00
syuilo
4f20c87186 lint fixes 2023-10-09 13:32:41 +09:00
syuilo
a2d3544a08 refactor(backend): better argument name 2023-10-09 13:22:58 +09:00
syuilo
1564651bf6 2023.10.0-beta.10 2023-10-09 12:37:21 +09:00
syuilo
fce557715b New Crowdin updates (#11980)
* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Chinese Traditional)
2023-10-09 12:37:04 +09:00
_
ca07459f5e fix(backend): ダイレクト投稿がタイムライン上に正常に表示されない問題を修正 (#11993)
* DMをredisにpushするように

* add test

* add CHANGELOG

* Update NoteCreateService.ts

* lint

* ✌️

* 前のバージョンから発生した問題ではないため不要

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-10-09 12:36:25 +09:00
syuilo
457b4cf608 fix(backend): users/notes で 自身の visibility: followers なノートが含まれない問題を修正 2023-10-09 08:54:57 +09:00
syuilo
5601ed0914 enhance(backend): UserListMembershipにユーザーリストの作成者IDを非正規化 2023-10-09 08:46:05 +09:00
syuilo
ca022cbbdf Update about-misskey.vue 2023-10-08 18:04:56 +09:00
syuilo
0e6cd577cc Merge pull request #11926 from misskey-dev/develop
* fix(backend): Redisに古いMisskeyバージョンのキャッシュが残っている場合の問題を修正

* Update CHANGELOG.md

* enhance(front)end: improve moderation log

* enhance: ノートの翻訳機能の利用可否をロールで設定可能に

Resolve #11923

* 2023.9.3

* 後方互換性の強化

* Update CHANGELOG.md

* fix test

* [ci skip] New Crowdin updates (#11922)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Thai)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Simplified)

* feat: AiScriptでホストのアドレスを参照できる定数 (#11924)

* add HOST_URL

* Update CHANGELOG.md

* tweak

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

---------

Co-authored-by: FineArchs <133759614+FineArchs@users.noreply.github.com>
2023-09-30 09:40:00 +09:00
syuilo
7adc8fcaf5 Merge pull request #11920 from misskey-dev/develop
Release: 2023.9.2
2023-09-29 18:11:30 +09:00
syuilo
e57b536767 Merge pull request #11898 from misskey-dev/develop
2023.9.1
2023-09-25 17:12:28 +09:00
syuilo
f32915b515 Merge pull request #11874 from misskey-dev/develop
Release: 2023.9.0
2023-09-24 18:21:31 +09:00
syuilo
a8d45d4b0d Merge pull request #11384 from misskey-dev/develop
Release: 13.14.2
2023-07-27 13:00:14 +09:00
syuilo
4e24aff408 Merge pull request #11338 from misskey-dev/develop
Release: 13.14.1
2023-07-21 20:40:03 +09:00
syuilo
e64a81aa1d Merge pull request #11301 from misskey-dev/develop
Release: 13.14.0
2023-07-21 20:36:07 +09:00
syuilo
7093662ce5 Merge pull request #10990 from misskey-dev/develop
Release: 13.13.2
2023-06-13 16:46:01 +09:00
syuilo
32c741154d Merge pull request #10961 from misskey-dev/develop
Release: 13.13.1
2023-06-06 11:34:36 +09:00
syuilo
407a965c1d Merge pull request #10932 from misskey-dev/develop
Release: 13.13.0
2023-06-05 19:47:08 +09:00
syuilo
de6348e8a0 Merge pull request #10833 from misskey-dev/develop
* refactor(frontend): use css modules

* feat: 投稿したコンテンツのAIによる学習を軽減するオプションを追加

Resolve #10819

* enhance(backend): publicReactionsをデフォルトtrueに

* 念のためnoimageaiもつける

* add X-Robots-Tag: noai

* Update ja-JP.yml

* fix(frontend): ブラーエフェクトを有効にしている状態で高負荷になる問題を修正

* enhance(backend): graceful shutdown for job queue and refactor

* fix(backend): テスト時は一部のサービスを停止

* fix test

* New Crowdin updates (#10815)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (Chinese Traditional)

* refactor

* bump

* refactor(frontend): use css module

* refactor(frontend): use css module

* delete unused component

* センシティブワードを正規表現、CWにも適用するように (#10688)

* cwにセンシティブが効いてない

* CWが無いときにTextを見るように

* 比較演算子間違えた

* とりあえずチェック

* 正規表現対応

* /test/giにも対応

* matchでしなくてもいいのでは感

* レビュー修正

* Update packages/backend/src/core/NoteCreateService.ts

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* Update packages/backend/src/core/NoteCreateService.ts

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* 修正

* wipかも

* wordsでスペース区切りのものできたかも

* なんか動いたかも

* test作成

* 文言の修正

* 修正

* note参照

---------

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* Update CHANGELOG.md

* New Crowdin updates (#10823)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (German)

* ci: fix typo

* fix(frontend): より明確な説明にしたのとtypo修正

* fix typo

* fix(frontend): カラーバーがリプライには表示されないのを修正

* fix(frontend): チャンネル内の検索ボックスが挙動不審な問題を修正

Fix #10793

* enhance(backend): ノートのハッシュタグもMeilisearchに突っ込むように

今後ハッシュタグ検索とか実装するときのため

* feat(frontend): ユーザー指定ノート検索

* fix(frontend): fix retention chart rendering

* Update about-misskey.vue

* meta: Remove @rinsuki from reviewer-lottery (#10830)

* New Crowdin updates (#10824)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Norwegian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Indonesian)

* New translations ja-JP.yml (Thai)

* enhance(frontend): アカウント初期設定ウィザードにプライバシー設定を追加

* Update CHANGELOG.md

* fix(backend): ひとつのMeilisearchサーバーを複数のMisskeyサーバーで使えない問題を修正

* fix MkUserSetupDialog.Privacy.vue

* ci: skip non-Japanese locale on TurboSnap

* ci: notify on changes for push events

* ci: fix missing branch

* Update basic.cy.js

* [ci skip] New Crowdin updates (#10834)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Arabic)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Arabic)

* 🎨

* 🎨

* enhance(frontend): add retention line chart

* update deps

* refactor

* fix(frontend): Pageにおいて画像ブロックに画像を設定できない問題を修正

Fix #10837

---------

Co-authored-by: nenohi <kimutipartylove@gmail.com>
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
Co-authored-by: rinsuki <428rinsuki+git@gmail.com>
2023-05-12 12:41:53 +09:00
syuilo
9ad57324db Merge pull request #10814 from misskey-dev/develop
Release: 13.12.1
2023-05-09 15:38:17 +09:00
syuilo
94690c835e Merge pull request #10774 from misskey-dev/develop
Release: 13.12.0
2023-05-09 09:17:34 +09:00
syuilo
c5d2dba28d Merge pull request #10608 from misskey-dev/develop
Release: 13.11.3
2023-04-13 12:18:07 +09:00
syuilo
272e0c874f Merge pull request #10606 from misskey-dev/EbiseLutica-patch-1
Update CHANGELOG.md
2023-04-13 08:35:14 +09:00
Ebise Lutica
d429f810a9 Update CHANGELOG.md 2023-04-13 00:31:22 +09:00
syuilo
75b28d6782 Merge pull request #10578 from misskey-dev/develop
Release: 13.11.2
2023-04-11 15:51:07 +09:00
syuilo
8b1362ab03 Merge pull request #10543 from misskey-dev/develop
Release: 13.11.1
2023-04-09 10:29:36 +09:00
syuilo
a096f621cf Merge pull request #10506 from misskey-dev/develop
13.11.0
2023-04-08 21:27:21 +09:00
syuilo
f54a9542bb Merge pull request #10402 from misskey-dev/develop
Release: 13.10.3
2023-03-25 08:36:41 +09:00
syuilo
a52bbc7c8d Merge pull request #10388 from misskey-dev/develop
Release: 13.10.2
2023-03-22 18:47:10 +09:00
syuilo
59768bdf3f Merge pull request #10383 from misskey-dev/develop
Release: 13.10.1
2023-03-22 16:30:36 +09:00
syuilo
1e67e9c661 Merge pull request #10342 from misskey-dev/develop
Release: 13.10.0
2023-03-22 09:55:38 +09:00
syuilo
ae517a99a7 Merge pull request #10218 from misskey-dev/develop
Release: 13.9.2
2023-03-06 11:54:12 +09:00
syuilo
b23a9b1a88 Merge pull request #10181 from misskey-dev/develop
Release: 13.9.1
2023-03-03 20:56:50 +09:00
syuilo
5bd68aa3e0 Merge pull request #10177 from misskey-dev/develop
Release: 13.9.0
2023-03-03 15:35:40 +09:00
syuilo
647ce174b3 Merge pull request #10112 from misskey-dev/develop
Release: 13.8.1
2023-02-26 20:57:13 +09:00
syuilo
02c8fd9de5 Merge pull request #10108 from misskey-dev/develop
* Add dialog to remove follower (#9718)

* update PULL_REQUEST_TEMPLATE

* 起動時にRedisの疎通確認を行う (#9832)

* 起動時にRedisの疎通確認を行う

* check:connectをstart内に移動

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* Pass `--detectOpenHandles` to Jest (#9895)

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* enhance(client): MkUrlPreviewの閉じるボタンを見やすく (#9913)

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* test(backend): restore ap-request tests (#9997)

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* fix/refaftor(client): MkTime.vueの変更 (#10061)

* fix(client): MkTime.timeにstringでもDateでない値が入った場合、?を表示

* fix(client): MkTimeを改良

* numberを許容

* falsyな値もとる

* 不明

* ありません

* fix

* fix(server): notes/createで、fileIdsと見つかったファイルの数が異なる場合はエラーにする (#9911)

* fix(server): notes/createで、fileIdsと見つかったファイルの数が異なる場合はエラーにする

* NO_SUCH_FILE

* Update codecov.yml

* Update apple-touch-icon.png

* デプロイされているプレビュー環境がない場合はプレビュー環境を削除しないようにする (#10062)

* デプロイされているプレビュー環境がない場合はDestroy preview environmentを実行しないようにする

* CIがない場合の処理追加

* enhance(client): improve clip menu ux

* 未知のユーザーが deleteActor されたら処理をスキップする (#10067)

* fix(client): Android ChromeでPWAとしてインストールできない問題を修正 (#10069)

* fix(client): Android ChromeでPWAとしてインストールできない問題を修正

* 順番関係ある?

* Windows環境でswcを使うと正常にビルドができない問題の修正 (#10074)

* Update @swc/core to v1.3.36

* Update CHANGELOG.md

* Update CHANGELOG.md

* バックグラウンドで一定時間経過したらページネーションのアイテム更新をしない (#10053)

* 🎨

* feat: 2つの検索画面の統合 (#9949) (#10038)

* feat: 検索画面の UI を統一

* fix: エラーの修正

* add: changelog

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* enhance(client): ノートメニューからユーザーメニューを開けるように

Resolve #10019

* enhance(client): renoteした際の表示を改善

Resolve #10078

* Update CHANGELOG.md

* enhance(client): tweak contextmenu position calculation

* 🎨

* 🎨

* feat: in-channel featured note

Resolve #9938

* refactor(frontend): fix eslint error (#10084)

* Simplify search.vue (remove dead code) (#10088)

* Simplify search.vue

This is already handled by the code above it, no need to handle it twice

* Remove unused imports

* Update about-misskey.vue

* test(server): add validation test of api:notes/create (#10090)

* fix(server): notes/createのバリデーションが効いていない
Fix #10079

Co-Authored-By: mei23 <m@m544.net>

* anyOf内にバリデーションを書いても最初の一つしかチェックされない

* ✌️

* wip

* wip

* ✌️

* RequiredProp

* Revert "RequiredProp"

This reverts commit 7469390011.

* add api:notes/create

* fix lint

* text

* ✌️

* improve readability

---------

Co-authored-by: mei23 <m@m544.net>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* New Crowdin updates (#10059)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Romanian)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Arabic)

* New translations ja-JP.yml (Czech)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Polish)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Slovak)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Vietnamese)

* New translations ja-JP.yml (Indonesian)

* New translations ja-JP.yml (Bengali)

* New translations ja-JP.yml (Thai)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Thai)

* New translations ja-JP.yml (Thai)

* New translations ja-JP.yml (Thai)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Spanish)

* enhance(client): improve user menu ux

* enhance(client): photoswipe 表示時に戻る操作をしても前の画面に戻らないように (#10098)

* enhance(client): photoswipe 表示時に戻る操作をしても前の画面に戻らないように

* add: changelog

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* enhance(client): メニューの「もっと」からインスタンス情報を見れるように

* [Fix] fixed an typo in error message (#10102)

* Update codecov.yml

* Update CHANGELOG.md

* fix(server): エラーのスタックトレースは返さないように

Fix #10064

* [chore]Editorconfig: ymlに加えてyamlファイルに対しても同じ規約を適用する (#10081)

* Added yaml file in addition to yml file, in editorconfig

* Applied editorconfig for pnpm-workspace.yaml

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* update deps

* ホームタイムラインの読み込みでクエリタイムアウトになるのを修正する (#10106)

* refactor

* New translations ja-JP.yml (French) (#10103)

* Update CHANGELOG.md

* 13.8.0

---------

Co-authored-by: atsuchan <83960488+atsu1125@users.noreply.github.com>
Co-authored-by: Masaya Suzuki <15100604+massongit@users.noreply.github.com>
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
Co-authored-by: Kagami Sascha Rosylight <saschanaz@outlook.com>
Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
Co-authored-by: xianon <xianon@hotmail.co.jp>
Co-authored-by: kabo2468 <28654659+kabo2468@users.noreply.github.com>
Co-authored-by: YS <47836716+yszkst@users.noreply.github.com>
Co-authored-by: Khsmty <me@khsmty.com>
Co-authored-by: Soni L <EnderMoneyMod@gmail.com>
Co-authored-by: mei23 <m@m544.net>
Co-authored-by: daima3629 <52790780+daima3629@users.noreply.github.com>
Co-authored-by: Windymelt <1113940+windymelt@users.noreply.github.com>
2023-02-26 20:21:54 +09:00
syuilo
1ba49b614d Merge pull request #10058 from misskey-dev/develop
Release: 13.7.5
2023-02-24 13:06:55 +09:00
tamaina
40de14415c Release: 13.7.4
Merge pull request #10050 from misskey-dev/develop
2023-02-23 23:11:25 +09:00
tamaina
7c9330a02f Release: 13.7.3
Merge pull request #10048 from misskey-dev/develop
2023-02-23 22:15:56 +09:00
92 changed files with 1676 additions and 1008 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
locales/index.d.ts vendored
View File

@@ -1796,9 +1796,6 @@ export interface Locale {
"infoFg": string;
"infoWarnBg": string;
"infoWarnFg": string;
"cwBg": string;
"cwFg": string;
"cwHoverBg": string;
"toastBg": string;
"toastFg": string;
"buttonBg": string;
@@ -2294,6 +2291,15 @@ export interface Locale {
"deleteAd": string;
"updateAd": string;
};
"_fileViewer": {
"title": string;
"type": string;
"size": string;
"url": string;
"uploadedAt": string;
"attachedNotes": string;
"thisPageCanBeSeenFromTheAuthor": string;
};
}
declare const locales: {
[lang: string]: Locale;

View File

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

View File

@@ -1714,9 +1714,6 @@ _theme:
infoFg: "情報の文字"
infoWarnBg: "警告の背景"
infoWarnFg: "警告の文字"
cwBg: "CW ボタンの背景"
cwFg: "CW ボタンの文字"
cwHoverBg: "CW ボタンの背景 (ホバー)"
toastBg: "通知トーストの背景"
toastFg: "通知トーストの文字"
buttonBg: "ボタンの背景"
@@ -2206,3 +2203,12 @@ _moderationLogTypes:
createAd: "広告を作成"
deleteAd: "広告を削除"
updateAd: "広告を更新"
_fileViewer:
title: "ファイルの詳細"
type: "ファイルタイプ"
size: "ファイルサイズ"
url: "URL"
uploadedAt: "追加日"
attachedNotes: "添付されているノート"
thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -138,12 +138,10 @@ if (props.src === 'antenna') {
} else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
};
connection = stream.useChannel('userList', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2755,6 +2755,9 @@ type Notification_2 = {
invitation: UserGroup;
user: User;
userId: User['id'];
} | {
type: 'achievementEarned';
achievement: string;
} | {
type: 'app';
header?: string | null;
@@ -2765,7 +2768,7 @@ type Notification_2 = {
});
// @public (undocumented)
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app"];
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "achievementEarned"];
// @public (undocumented)
type OriginType = 'combined' | 'local' | 'remote';
@@ -2981,7 +2984,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:630:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/entities.ts:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
// src/entities.ts:597:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
// src/entities.ts:600:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)

View File

@@ -23,10 +23,10 @@
"@microsoft/api-extractor": "7.38.0",
"@swc/jest": "0.2.29",
"@types/jest": "29.5.5",
"@types/node": "20.8.2",
"@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.4",
"eslint": "8.50.0",
"@types/node": "20.8.4",
"@typescript-eslint/eslint-plugin": "6.7.5",
"@typescript-eslint/parser": "6.7.5",
"eslint": "8.51.0",
"jest": "29.7.0",
"jest-fetch-mock": "3.0.3",
"jest-websocket-mock": "2.5.0",

View File

@@ -67,8 +67,7 @@ export class APIClient {
IsCaseMatched<E, P, 8> extends true ? GetCaseResult<E, P, 8> :
IsCaseMatched<E, P, 9> extends true ? GetCaseResult<E, P, 9> :
Endpoints[E]['res']['$switch']['$default']
: Endpoints[E]['res']>
{
: Endpoints[E]['res']> {
const promise = new Promise((resolve, reject) => {
this.fetch(`${this.origin}/api/${endpoint}`, {
method: 'POST',

View File

@@ -1,4 +1,4 @@
export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const;
export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'achievementEarned'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;

View File

@@ -277,6 +277,9 @@ export type Notification = {
invitation: UserGroup;
user: User;
userId: User['id'];
} | {
type: 'achievementEarned';
achievement: string;
} | {
type: 'app';
header?: string | null;

View File

@@ -38,6 +38,9 @@ module.exports = {
'before': true,
'after': true,
}],
'brace-style': ['error', '1tbs', {
'allowSingleLine': true,
}],
'padded-blocks': ['error', 'never'],
/* TODO: path aliasを使わないとwarnする
'no-restricted-imports': ['warn', {

View File

@@ -14,9 +14,9 @@
"misskey-js": "workspace:*"
},
"devDependencies": {
"@typescript-eslint/parser": "6.7.4",
"@typescript-eslint/parser": "6.7.5",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
"eslint": "8.50.0",
"eslint": "8.51.0",
"eslint-plugin-import": "2.28.1",
"typescript": "5.2.2"
},

View File

@@ -15,7 +15,7 @@ import { getUrlWithLoginId } from '@/scripts/login-id.js';
export const cli = new Misskey.api.APIClient({ origin, fetch: (...args): Promise<Response> => fetch(...args) });
export async function api<E extends keyof Misskey.Endpoints, O extends Misskey.Endpoints[E]['req']>(endpoint: E, userId?: string, options?: O): Promise<void | ReturnType<typeof cli.request<E, O>>> {
let account: { token: string; id: string } | void;
let account: { token: string; id: string } | void = undefined;
if (userId) {
account = await getAccountFromId(userId);

662
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff