diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca5870e76..559e2d8f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ ChangeLog ========= +12.44.1 (2020/7/29) +------------------- +### 🐛Fixes +- 通知が流れない問題を修正 [9f94f60](https://github.com/syuilo/misskey/commit/9f94f60ededccfb3ff109aef1241be633d27eaa7) + +12.44.0 (2020/7/29) +------------------- +### ✨Improvements +- ワードミュートの実装 [#6594](https://github.com/syuilo/misskey/pull/6594) +- ページのリストをタブUIに [6b8354c](https://github.com/syuilo/misskey/commit/6b8354ccbfa1d96b4445013d2e93af8e06550516) +- プラグインを無効にできるように [595ad04](https://github.com/syuilo/misskey/commit/595ad04ddbbf9ff9fc6842f345d4738a9f1cc150) +- AiScript: ノート書き換えAPI [30df8ea](https://github.com/syuilo/misskey/commit/30df8ea1213013072f139aa26a635330457cf2bc) +- クライアントのソースコードのリファクタ [b5a1fdd](https://github.com/syuilo/misskey/commit/b5a1fdd4c7597ebdd4ab6022e189da9ca3451dbb), [14b7f05](https://github.com/syuilo/misskey/commit/14b7f05af40ede154a767334dbbefc3458584290), [0efa969](https://github.com/syuilo/misskey/commit/0efa969a153a060d232a0e31b10577ece87faeae), [a8adc46](https://github.com/syuilo/misskey/commit/a8adc46f3ba42e86c64a64f2633f5796aeca01f4), [1b9d316](https://github.com/syuilo/misskey/commit/1b9d316e7c2446211f4b5b6ec27dce0d9b4f0968) + +12.43.0 (2020/7/26) +------------------- +*このアップデートでは、データベースのマイグレーション(`npm run migrate`/`yarn migrate`)が必要です。* + +### ✨Improvements +- 連合ウィジェットを追加 [186b26e](https://github.com/syuilo/misskey/commit/186b26e103d5dc893a741ab9c5805b5dc81f14c0), [e1f2e36](https://github.com/syuilo/misskey/commit/e1f2e364a4347a8da78a32ed741c789a288d3957), [bd54e44](https://github.com/syuilo/misskey/commit/bd54e44b35f7aeae8766054322e2908881323041), [58211fc](https://github.com/syuilo/misskey/commit/58211fc6a72536b066bd8a78fb4bb083cfc1051a), [e5863c2](https://github.com/syuilo/misskey/commit/e5863c2867c1ee8d0d6f2257de7f7fc7791cf8a6), [55be9cc](https://github.com/syuilo/misskey/commit/55be9cc9d130cca541cfe0569885db4d79a58128) + * 連合ウィジェットは、最近着信のあったリモートのインスタンスを表示します。 +- リモートのインスタンスのアイコンを取得して表示するように [#6591](https://github.com/syuilo/misskey/pull/6591), [b07d037](https://github.com/syuilo/misskey/commit/b07d037cb5b1531c38cb2d56ff612bdba5c58a3f), [3f2ffce](https://github.com/syuilo/misskey/commit/3f2ffcea97b6496053fd4027192976bfad2626b0) +- インスタンス設定の不足分を追加 [#6576](https://github.com/syuilo/misskey/pull/6576) +- クライアントでのソースコードのリファクタ・パフォーマンス改善 + * lintでのエラーを修正 [#6568](https://github.com/syuilo/misskey/pull/6568) + * ~~vue-i18nのv-tを使うように [9c30b23](https://github.com/syuilo/misskey/commit/9c30b23358699a530f2bcb0f5ae6efe17146bcb3)~~ [166bc19](https://github.com/syuilo/misskey/commit/166bc19131ae4b40bdd5e85269729f6eb5e3d931) + * 静的な内容にv-onceを付加 [da874f3](https://github.com/syuilo/misskey/commit/da874f3383088dddbf7ce441b0c9d8f6512dfc9b) + +### 🐛Fixes +- 投票の残り時間表示の修正 [#6565](https://github.com/syuilo/misskey/pull/6565) +- blurhashにした影響で猫耳の色をアイコンに合わせられなくなっているのを修正 [#6585](https://github.com/syuilo/misskey/pull/6585), [7e2b6b6](https://github.com/syuilo/misskey/commit/7e2b6b6369a5eecad2374b84527dca1a712053c9) +- 脆弱性のある依存関係をアップデート [#6572](https://github.com/syuilo/misskey/pull/6572) +- blurhashのテストを修正 [#6573](https://github.com/syuilo/misskey/pull/6573) +- Deckであなた宛て・ダイレクトカラムを追加するとメインカラムに文字が重なる問題を修正 [#6577](https://github.com/syuilo/misskey/pull/6577) +- Deckの翻訳を追加 [#6567](https://github.com/syuilo/misskey/pull/6567) +- アンテナカラムの挙動を正常化 [#6567](https://github.com/syuilo/misskey/pull/6567) +- ウィジェットカラムの挙動を正常化して編集モードの見栄えを良くした [#6567](https://github.com/syuilo/misskey/pull/6567) + 12.42.0 (2020/7/19) ------------------- *このアップデートでは、データベースのマイグレーション(`npm run migrate`/`yarn migrate`)が必要です。* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9612d6e2bd..2ffe6dc2e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,12 @@ # Contribution guide :v: Thanks for your contributions :v: +## When you contribute... +- 任意のIssueについて、せっかく実装してくださっても、実装方法や設計の認識が揃ってないとマージできない/しないことになりかねないので、初めにそのIssue上で着手することを宣言し、必要に応じて他メンバーと実装方法や設計のすり合わせを行ってください。宣言することは作業が他の人と被るのを防止する効果もあります。 + - 設計に迷った時はプロジェクトリーダーの判断を仰いでください。 +- 時間や優先度の都合上、提出してくださったPRが長期間放置されることもありますがご理解ください。 + - 温度感高めで見てほしいものは責付いてください。 + ## Issues Feature suggestions and bug reports are filed in https://github.com/syuilo/misskey/issues . diff --git a/README.md b/README.md index aacdfee96c..263e7b44ca 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,6 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). <td><img src="https://c8.patreon.com/2/200/20832595" alt="Roujo " width="100"></td> <td><img src="https://c8.patreon.com/2/200/27956229" alt="Oliver Maximilian Seidel" width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12190916/fb7fa7983c14425f890369535b1506a4/3.png?token-time=2145916800&token-hash=oH_i7gJjNT7Ot6j9JiVwy7ZJIBqACVnzLqlz4YrDAZA%3D" alt="weepjp " width="100"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/605366/c9dc408fdcbf412fb183ca5b06235f8d/1.jpeg?token-time=2145916800&token-hash=oaqsjLqOFjWN5I9hm2epOaTXaEtKwQUy5OW-EpAz6-g%3D" alt="Jon Leibowitz" width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/19045173/cb91c0f345c24d4ebfd05f19906d5e26/1.png?token-time=2145916800&token-hash=o_zKBytJs_AxHwSYw_5R8eD0eSJe3RoTR3kR3Q0syN0%3D" alt="kiritan " width="100"></td> <td><img src="https://c8.patreon.com/2/200/27648259" alt="みなしま " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/24430516/b1964ac5b9f746d2a12ff53dbc9aa40a/1.jpg?token-time=2145916800&token-hash=bmEiMGYpp3bS7hCCbymjGGsHBZM3AXuBOFO3Kro37PU%3D" alt="Eduardo Quiros" width="100"></td> @@ -120,7 +119,6 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). <td><a href="https://www.patreon.com/user?u=20832595">Roujo </a></td> <td><a href="https://www.patreon.com/user?u=27956229">Oliver Maximilian Seidel</a></td> <td><a href="https://www.patreon.com/weepjp">weepjp </a></td> -<td><a href="https://www.patreon.com/jonleibowitz">Jon Leibowitz</a></td> <td><a href="https://www.patreon.com/user?u=19045173">kiritan </a></td> <td><a href="https://www.patreon.com/user?u=27648259">みなしま </a></td> <td><a href="https://www.patreon.com/user?u=24430516">Eduardo Quiros</a></td> @@ -135,7 +133,7 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). <td><img src="https://c8.patreon.com/2/200/21285325" alt="Nie(sha) " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5670915/ee175f0bfb6347ffa4ea101a8c097bff/1.jpg?token-time=2145916800&token-hash=mPLM9CA-riFHx-myr3bLZJuH2xBRHA9se5VbHhLIOuA%3D" alt="osapon " width="100"></td> <td><img src="https://c8.patreon.com/2/200/16869916" alt="見当かなみ " width="100"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/18899730/6a22797f68254034a854d69ea2445fc8/1.png?token-time=2145916800&token-hash=b_uj57yxo5VzkSOUS7oXE_762dyOTB_oxzbO6lFNG3k%3D" alt="YuzuRyo61 " width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/36813045/29876ea679d443bcbba3c3f16edab8c2/2.jpeg?token-time=2145916800&token-hash=YCKWnIhrV9rjUCV9KqtJnEqjy_uGYF3WMXftjUdpi7o%3D" alt="Wataru Manji (manji0)" width="100"></td> </tr><tr> <td><a href="https://www.patreon.com/Nesakko">Nesakko</a></td> <td><a href="https://www.patreon.com/user?u=776209">Demogrognard</a></td> @@ -146,9 +144,10 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). <td><a href="https://www.patreon.com/user?u=21285325">Nie(sha) </a></td> <td><a href="https://www.patreon.com/osapon">osapon </a></td> <td><a href="https://www.patreon.com/user?u=16869916">見当かなみ </a></td> -<td><a href="https://www.patreon.com/Yuzulia">YuzuRyo61 </a></td> +<td><a href="https://www.patreon.com/user?u=36813045">Wataru Manji (manji0)</a></td> </tr></table> <table><tr> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/18899730/6a22797f68254034a854d69ea2445fc8/1.png?token-time=2145916800&token-hash=b_uj57yxo5VzkSOUS7oXE_762dyOTB_oxzbO6lFNG3k%3D" alt="YuzuRyo61 " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5788159/af42076ab3354bb49803cfba65f94bee/1.jpg?token-time=2145916800&token-hash=iSaxp_Yr2-ZiU2YVi9rcpZZj9mj3UvNSMrZr4CU4qtA%3D" alt="mewl hayabusa" width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/28779508/3cd4cb7f017f4ee0864341e3464d42f9/1.png?token-time=2145916800&token-hash=eGQtR15be44kgvh8fw2Jx8Db4Bv15YBp2ldxh0EKRxA%3D" alt="S Y" width="100"></td> <td><img src="https://c8.patreon.com/2/200/16542964" alt="Takumi Sugita" width="100"></td> @@ -156,8 +155,8 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5881381/6235ca5d3fb04c8e95ef5b4ff2abcc18/3.png?token-time=2145916800&token-hash=KjfQL8nf3AIf6WqzLshBYAyX44piAqOAZiYXgZS_H6A%3D" alt="YUKIMOCHI" width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/26340354/08834cf767b3449e93098ef73a434e2f/2.png?token-time=2145916800&token-hash=nyM8DnKRL8hR47HQ619mUzsqVRpkWZjgtgBU9RY15Uc%3D" alt="totokoro " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/19356899/496b4681d33b4520bd7688e0fd19c04d/2.jpeg?token-time=2145916800&token-hash=_sTj3dUBOhn9qwiJ7F19Qd-yWWfUqJC_0jG1h0agEqQ%3D" alt="sheeta.s " width="100"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5827393/59893c191dda408f9cabd0f20a3a5627/1.jpeg?token-time=2145916800&token-hash=i9N05vOph-eP1LTLb9_npATjYOpntL0ZsHNaZFSsPmE%3D" alt="motcha " width="100"></td> </tr><tr> +<td><a href="https://www.patreon.com/Yuzulia">YuzuRyo61 </a></td> <td><a href="https://www.patreon.com/hs_sh_net">mewl hayabusa</a></td> <td><a href="https://www.patreon.com/user?u=28779508">S Y</a></td> <td><a href="https://www.patreon.com/user?u=16542964">Takumi Sugita</a></td> @@ -165,50 +164,51 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). <td><a href="https://www.patreon.com/yukimochi">YUKIMOCHI</a></td> <td><a href="https://www.patreon.com/user?u=26340354">totokoro </a></td> <td><a href="https://www.patreon.com/user?u=19356899">sheeta.s </a></td> -<td><a href="https://www.patreon.com/user?u=5827393">motcha </a></td> </tr></table> <table><tr> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5827393/59893c191dda408f9cabd0f20a3a5627/1.jpeg?token-time=2145916800&token-hash=i9N05vOph-eP1LTLb9_npATjYOpntL0ZsHNaZFSsPmE%3D" alt="motcha " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/20494440/540beaf2445f408ea6597bc61e077bb3/1.png?token-time=2145916800&token-hash=UJ0JQge64Bx9XmN_qYA1inMQhrWf4U91fqz7VAKJeSg%3D" alt="axtuki1 " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/13737140/1adf7835017d479280d90fe8d30aade2/1.png?token-time=2145916800&token-hash=0pdle8h5pDZrww0BDOjdz6zO-HudeGTh36a3qi1biVU%3D" alt="Satsuki Yanagi" width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/17880724/311738c8a48f4a6b9443c2445a75adde/1.jpg?token-time=2145916800&token-hash=nVAntpybQrznE0rg05keLrSE6ogPKJXB13rmrJng42c%3D" alt="takimura " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/13100201/fc5be4fa90444f09a9c8a06f72385272/1.png?token-time=2145916800&token-hash=i8PjlgfOB2LPEdbtWyx8ZPsBKhGcNZqcw_FQmH71UGU%3D" alt="aqz tamaina" width="100"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/28295158/cd2451bfb94a449dbf705ef4718cd355/2.jpeg?token-time=2145916800&token-hash=MRv3BxufHPuCyiBSxU5UYmLGvD6YZlhtSFRfMWg2k4U%3D" alt="012 " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/9109588/e3cffc48d20a4e43afe04123e696781d/3.png?token-time=2145916800&token-hash=T_VIUA0IFIbleZv4pIjiszZGnQonwn34sLCYFIhakBo%3D" alt="nafuchoco " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/16900731/619ab87cc08448439222631ebb26802f/1.gif?token-time=2145916800&token-hash=o27K7M02s1z-LkDUEO5Oa7cu-GviRXeOXxryi4o_6VU%3D" alt="Atsuko Tominaga" width="100"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/4389829/9f709180ac714651a70f74a82f3ffdb9/3.png?token-time=2145916800&token-hash=FTm3WVom4dJ9NwWMU4OpCL_8Yc13WiwEbKrDPyTZTPs%3D" alt="natalie " width="100"></td> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/4389829/9f709180ac714651a70f74a82f3ffdb9/3.png?token-time=2145916800&token-hash=FTm3WVom4dJ9NwWMU4OpCL_8Yc13WiwEbKrDPyTZTPs%3D" alt="natalie" width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/26144593/9514b10a5c1b42a3af58621aee213d1d/1.png?token-time=2145916800&token-hash=v1PYRsjzu4c_mndN4Hvi_dlispZJsuGRCQeNS82pUSM%3D" alt="EBISUME" width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5923936/2a743cbfbff946c2af3f09026047c0da/2.png?token-time=2145916800&token-hash=h6yphW1qnM0n_NOWaf8qtszMRLXEwIxfk5beu4RxdT0%3D" alt="noellabo " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/2384390/5681180e1efb46a8b28e0e8d4c8b9037/1.jpg?token-time=2145916800&token-hash=SJcMy-Q1BcS940-LFUVOMfR7-5SgrzsEQGhYb3yowFk%3D" alt="CG " width="100"></td> -<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/18072312/98e894d960314fa7bc236a72a39488fe/1.jpg?token-time=2145916800&token-hash=7bkMqTwHPRsJPGAq42PYdDXDZBVGLqdgr1ZmBxX8GFQ%3D" alt="Hekovic " width="100"></td> </tr><tr> +<td><a href="https://www.patreon.com/user?u=5827393">motcha </a></td> <td><a href="https://www.patreon.com/user?u=20494440">axtuki1 </a></td> <td><a href="https://www.patreon.com/user?u=13737140">Satsuki Yanagi</a></td> <td><a href="https://www.patreon.com/takimura">takimura </a></td> <td><a href="https://www.patreon.com/aqz">aqz tamaina</a></td> -<td><a href="https://www.patreon.com/user?u=28295158">012 </a></td> -<td><a href="https://www.patreon.com/nijimiss">nafuchoco </a></td> +<td><a href="https://www.patreon.com/user?u=9109588">nafuchoco </a></td> <td><a href="https://www.patreon.com/user?u=16900731">Atsuko Tominaga</a></td> -<td><a href="https://www.patreon.com/user?u=4389829">natalie </a></td> +<td><a href="https://www.patreon.com/user?u=4389829">natalie</a></td> <td><a href="https://www.patreon.com/user?u=26144593">EBISUME</a></td> <td><a href="https://www.patreon.com/noellabo">noellabo </a></td> <td><a href="https://www.patreon.com/Corset">CG </a></td> -<td><a href="https://www.patreon.com/hekovic">Hekovic </a></td> </tr></table> <table><tr> +<td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/18072312/98e894d960314fa7bc236a72a39488fe/1.jpg?token-time=2145916800&token-hash=7bkMqTwHPRsJPGAq42PYdDXDZBVGLqdgr1ZmBxX8GFQ%3D" alt="Hekovic " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/24641572/b4fd175424814f15b0ca9178d2d2d2e4/1.png?token-time=2145916800&token-hash=e2fyqdbuJbpCckHcwux7rbuW6OPkKdERcus0u2wIEWU%3D" alt="uroco @99" width="100"></td> +<td><img src="https://c8.patreon.com/2/200/14661394" alt="Chandler " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5731881/4b6038e6cda34c04b83a5fcce3806a93/1.png?token-time=2145916800&token-hash=hBayGfOmQH3kRMdNnDe4oCZD_9fsJWSt29xXR3KRMVk%3D" alt="Nokotaro Takeda" width="100"></td> <td><img src="https://c8.patreon.com/2/200/23932002" alt="nenohi " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/9481273/7fa89168e72943859c3d3c96e424ed31/4.jpeg?token-time=2145916800&token-hash=5w1QV1qXe-NdWbdFmp1H7O_-QBsSiV0haumk3XTHIEg%3D" alt="Efertone " width="100"></td> <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12531784/93a45137841849329ba692da92ac7c60/1.jpeg?token-time=2145916800&token-hash=vGe7wXGqmA8Q7m-kDNb6fyGdwk-Dxk4F-ut8ZZu51RM%3D" alt="Takashi Shibuya" width="100"></td> </tr><tr> +<td><a href="https://www.patreon.com/hekovic">Hekovic </a></td> <td><a href="https://www.patreon.com/user?u=24641572">uroco @99</a></td> +<td><a href="https://www.patreon.com/user?u=14661394">Chandler </a></td> <td><a href="https://www.patreon.com/takenoko">Nokotaro Takeda</a></td> <td><a href="https://www.patreon.com/user?u=23932002">nenohi </a></td> <td><a href="https://www.patreon.com/efertone">Efertone </a></td> <td><a href="https://www.patreon.com/user?u=12531784">Takashi Shibuya</a></td> </tr></table> -**Last updated:** Tue, 02 Jun 2020 00:00:08 UTC +**Last updated:** Tue, 14 Jul 2020 09:00:09 UTC <!-- PATREON_END --> [backer-url]: #backers diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 5cdf07e3ec..fc41376954 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -193,6 +193,7 @@ rename: "إعادة التسمية" avatar: "الصورة الرمزية" banner: "الصورة الرأسية" nsfw: "محتوى حساس" +disconnectedFromServer: "قُطِع الإتصال بالخادم" reload: "انعش" doNothing: "تجاهل" watch: "راقب" @@ -255,6 +256,7 @@ unregister: "إلغاء التسجيل" passwordLessLogin: "لِج مِن دون كلمة سرية" resetPassword: "أعد تعيين كلمتك السرية" newPasswordIs: "كلمتك السرية الجديدة هي {password}" +autoReloadWhenDisconnected: "إنعاش تلقائي عندما يُقطَع الإتصال بالخادم" autoNoteWatch: "راقب الملاحظات تلقائيا" share: "شارِك" notFound: "غير موجود" @@ -311,6 +313,7 @@ remote: "بُعدي" total: "المجموع" weekOverWeekChanges: "أسبوعيا" dayOverDayChanges: "يوميا" +appearance: "المظهر" clinetSettings: "إعدادات التطبيق" accountSettings: "إعدادات الحساب" promotion: "ترقية" @@ -341,8 +344,21 @@ addRelay: "إضافة مُرحّل" addedRelays: "المرحلات التي تم إضافتها" deletedNote: "ملاحظة محذوفة" invisibleNote: "ملاحظة مخفية" +poll: "استطلاع رأي" +themeEditor: "مصمم القوالب" +plugins: "الإضافات" +pluginInstallWarn: "يرجى تنصيب إضافات ذات مصدر موثوق منه فقط." +smtpHost: "المضيف" +smtpUser: "اسم المستخدم" +smtpPass: "الكلمة السرية" _theme: explore: "استكشف قوالب المظهر" + install: "تنصيب قالب" + manage: "إدارة القوالب" + code: "شيفرة القالب" + installed: "تم تنصيب {name}" + make: "إنشاء قالب" + alpha: "الشفافية" keys: messageBg: "خلفية الدردشة" _sfx: @@ -392,18 +408,29 @@ _widgets: rss: "تدفق RSS" activity: "النشاط" photos: "الصور" + federation: "الفديرالية" _cw: hide: "إخفاء" show: "عرض المزيد" chars: "{count} أحرف" files: "{count} ملفات" _poll: + noOnlyOneChoice: "تحتاج إلى خيارَين على الأقل" + choiceN: "الخيار {n}" + noMore: "لا يمكنك إضافة خيارات أخرى" + canMultipleVote: "السماح بالإجابات المتعددة" + expiration: "ينتهي استطلاع الرأي في" + infinite: "أبدًا" at: "تاريخ الإنتهاء" + after: "ينتهي بعد…" deadlineDate: "تاريخ الانتهاء" deadlineTime: "سا" duration: "المدة" + votesCount: "{n} أصوات" + totalVotes: "المجموع {n} أصوات" vote: "قم بالتصويت" showResult: "اعرض النتائج" + voted: "تم التصويت" closed: "انتهى" remainingDays: "{d} أيام و {h} ساعات متبقية" remainingHours: "{h} ساعات و {m} دقائق متبقية" @@ -469,9 +496,13 @@ _pages: types: array: "القوائم" _notification: + youGotPoll: "شارك {name} في استطلاع الرأي" youGotMessagingMessageFromUser: "لقد تلقيت رسالة مِن {name}" youGotMessagingMessageFromGroup: "لقد أرسِلَت رسالة إلى الفريق {name}" youWereFollowed: "يتابعك" + _types: + follow: "المتابَعون" + quote: "اقتبس" _deck: _columns: notifications: "الإشعارات" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 25e7290462..8485f3bd0e 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -104,6 +104,8 @@ unblockConfirm: "Möchtest du diese Blockierung wirklich aufheben?" suspendConfirm: "Möchtest du diesen Benutzer wirklich sperren?" unsuspendConfirm: "Möchtest du die Sperrung dieses Benutzers wirklich aufheben?" selectList: "Wähle eine Liste aus" +selectAntenna: "Antenne auswählen" +selectWidget: "Widget auswählen" customEmojis: "Benutzerdefinierte Emojis" emoji: "Emoji" emojiName: "Emojiname" @@ -442,7 +444,7 @@ remote: "Fremd" total: "Gesamt" weekOverWeekChanges: "Wöchentlich" dayOverDayChanges: "Täglich" -accessibility: "Barrierefreiheit" +appearance: "Aussehen" clinetSettings: "Client-Einstellungen" accountSettings: "Benutzerkonto-Einstellungen" promotion: "Hervorgehoben" @@ -528,6 +530,40 @@ plugins: "Plugins" pluginInstallWarn: "Installiere nur vertrauenswürdige Plugins." deck: "Deck" undeck: "Deck verlassen" +useBlurEffectForModal: "Weichzeichnungseffekt für Modals verwenden" +generateAccessToken: "Zugriffstoken generieren" +permission: "Berechtigungen" +enableAll: "Alle aktivieren" +disableAll: "Alle deaktivieren" +tokenRequested: "Benutzerkontozugriff gewähren" +pluginTokenRequestedDescription: "Dieses Plugin wird die hier konfigurierten Berechtigungen verwenden können." +notificationType: "Benachrichtigungstyp" +edit: "Bearbeiten" +useStarForReactionFallback: "Verwende ★ falls das Reaktions-Emoji unbekannt ist" +emailConfig: "Email-Server Konfiguration" +enableEmail: "Email-Versand aktivieren" +emailConfigInfo: "Zur Email-Bestätigung bei Registrierung und zum Zurücksetzen des Passworts verwendet" +email: "Email-Adresse" +smtpConfig: "SMTP-Server Konfiguration" +smtpHost: "Host" +smtpPort: "Port" +smtpUser: "Benutzername" +smtpPass: "Passwort" +emptyToDisableSmtpAuth: "Benutzername und Passwort leer lassen um SMTP-Verifizierung zu deaktivieren" +smtpSecure: "Für SMTP-Verbindungen implizit SSL/TLS verwenden" +smtpSecureInfo: "Schalte dies aus, falls du STARTTLS verwendest" +testEmail: "Email-Versand testen" +wordMute: "Wort-Stummschaltung" +userSaysSomething: "{name} hat etwas gesagt." +makeActive: "Aktivieren" +_wordMute: + muteWords: "Wort stummschalten" + muteWordsDescription: "Mit Leerzeichen für eine \"UND\"-Verknüpfung trennen, durch Zeilenumbrüche für eine \"ODER\"-Verknüpfung trennen." + muteWordsDescription2: "Umgib Schlüsselworter mit Schrägstrichen, um Reguläre Ausdrücke zu verwenden." + softDescription: "Notizen, die die eingestellten Konditionen erfüllen, in der Chronik ausblenden" + hardDescription: "Verhindern, dass Notizen, die die eingestellten Konditionen erfüllen, der Chronik hinzugefügt werden. Zudem werden diese Notizen auch nicht der Chronik hinzugefügt, falls die Konditionen geändert werden." + soft: "Leicht" + hard: "Schwer" _theme: explore: "Themen erforschen" install: "Thema installieren" @@ -548,7 +584,7 @@ _theme: func: "Funktionen" funcKind: "Funktionstyp" argument: "Parameter" - basedProp: "Name der referenzierten Eigenschaft" + basedProp: "Referenzierte Eigenschaft" alpha: "Transparenz" darken: "Verdunkeln" lighten: "Erhellen" @@ -713,6 +749,7 @@ _widgets: activity: "Aktivität" photos: "Fotos" digitalClock: "Digitaluhr" + federation: "Föderation" _cw: hide: "Ausblenden" show: "Mehr anzeigen" @@ -1166,10 +1203,26 @@ _notification: youReceivedFollowRequest: "Du hast eine Follow-Anfrage erhalten" yourFollowRequestAccepted: "Deine Follow-Anfrage wurde akzeptiert" youWereInvitedToGroup: "Du wurdest in eine Gruppe eingeladen" + _types: + all: "Alle" + follow: "Folgt" + mention: "Erwähnung" + reply: "Antworten" + renote: "Renote" + quote: "Zitieren" + reaction: "Reaktionen" + pollVote: "Umfragen" + receiveFollowRequest: "Follow-Anfragen" _deck: alwaysShowMainColumn: "Hauptspalte immer zeigen" columnAlign: "Spalten ausrichten" addColumn: "Spalte hinzufügen" + swapLeft: "Nach links verschieben" + swapRight: "Nach rechts verschieben" + swapUp: "Nach oben verschieben" + swapDown: "Nach unten verschieben" + stackLeft: "Nach links stapeln" + popRight: "Nach rechts vom Stapel nehmen" _columns: widgets: "Widgets" notifications: "Benachrichtigungen" diff --git a/locales/en-US.yml b/locales/en-US.yml index d08191ad5e..aa4ada2b85 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -104,6 +104,8 @@ unblockConfirm: "Are you sure that you want to unblock this account?" suspendConfirm: "Are you sure that you want to suspend this account?" unsuspendConfirm: "Are you sure you that want to unsuspend this account?" selectList: "Select a list" +selectAntenna: "Select an Antenna" +selectWidget: "Select a widget" customEmojis: "Custom Emoji" emoji: "Emoji" emojiName: "Emoji name" @@ -442,7 +444,7 @@ remote: "Remote" total: "Total" weekOverWeekChanges: "Weekly" dayOverDayChanges: "Daily" -accessibility: "Accessibility" +appearance: "Appearance" clinetSettings: "Client Settings" accountSettings: "Account Settings" promotion: "Promoted" @@ -528,6 +530,40 @@ plugins: "Plugins" pluginInstallWarn: "Please do not install untrustworthy plugins." deck: "Deck" undeck: "Leave Deck" +useBlurEffectForModal: "Use blur effect for modals" +generateAccessToken: "Generate access token" +permission: "Permissions" +enableAll: "Enable all" +disableAll: "Disable all" +tokenRequested: "Grant access to account" +pluginTokenRequestedDescription: "This plugin will be able to use the permissions set here." +notificationType: "Notification type" +edit: "Edit" +useStarForReactionFallback: "Use ★ as fallback if the reaction emoji is unknown" +emailConfig: "Email server configuration" +enableEmail: "Enable email distribution" +emailConfigInfo: "Used to confirm your email during sign-up and if you forget your password" +email: "Email Address" +smtpConfig: "SMTP Server configuration" +smtpHost: "Host" +smtpPort: "Port" +smtpUser: "Username" +smtpPass: "Password" +emptyToDisableSmtpAuth: "Leave username and password empty to disable SMTP verification" +smtpSecure: "Use implicit SSL/TLS for SMTP connections" +smtpSecureInfo: "Turn this off when using STARTTLS" +testEmail: "Test email delivery" +wordMute: "Word mute" +userSaysSomething: "{name} said something" +makeActive: "Activate" +_wordMute: + muteWords: "Word to mute" + muteWordsDescription: "Separate with spaces for AND condition. Separate with line breaks for OR." + muteWordsDescription2: "Surround keywords by slashes to use regular expressions." + softDescription: "Hide notes fulfilling the set conditions from the timeline." + hardDescription: "Prevent notes fulfilling the set conditions from being added to the timeline. In addition, these notes will not be added to the timeline even if the conditions are changed." + soft: "Soft" + hard: "Hard" _theme: explore: "Explore Themes" install: "Install theme" @@ -548,7 +584,7 @@ _theme: func: "Functions" funcKind: "Function type" argument: "Argument" - basedProp: "Name of the referenced property" + basedProp: "Referenced property" alpha: "Opacity" darken: "Darken" lighten: "Lighten" @@ -713,6 +749,7 @@ _widgets: activity: "Activity" photos: "Photos" digitalClock: "Digital clock" + federation: "Federation" _cw: hide: "Hide" show: "Load more" @@ -1166,10 +1203,26 @@ _notification: youReceivedFollowRequest: "You've received a follow request" yourFollowRequestAccepted: "Your follow request was accepted" youWereInvitedToGroup: "Invited to group" + _types: + all: "All" + follow: "Following" + mention: "Mention" + reply: "Replies" + renote: "Renote" + quote: "Quote" + reaction: "Reaction" + pollVote: "Polls" + receiveFollowRequest: "Follow requests" _deck: alwaysShowMainColumn: "Always show main column" columnAlign: "Align columns" addColumn: "Add column" + swapLeft: "Swap to left" + swapRight: "Swap to right" + swapUp: "Swap with above" + swapDown: "Swap with below" + stackLeft: "Stack on the left" + popRight: "Pop to the right" _columns: widgets: "Widgets" notifications: "Notifications" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index e2a160c589..cc5d468816 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -442,7 +442,7 @@ remote: "Remoto" total: "Total" weekOverWeekChanges: "Dif semanal" dayOverDayChanges: "Dif diaria" -accessibility: "Accesibilidad" +appearance: "Apariencia" clinetSettings: "Ajustes del cliente" accountSettings: "Ajustes de cuenta" promotion: "Promovido" @@ -526,6 +526,29 @@ leaveConfirm: "Hay modificaciones sin guardar. ¿Desea descartarlas?" manage: "Administrar" plugins: "Plugins" pluginInstallWarn: "Por favor no instale plugins que no son de confianza" +deck: "Deck" +undeck: "Quitar deck" +useBlurEffectForModal: "Usar efecto borroso en modales" +generateAccessToken: "Generar token de acceso" +permission: "Permisos" +enableAll: "Activar todo" +disableAll: "Desactivar todo" +tokenRequested: "Permiso de acceso a la cuenta" +pluginTokenRequestedDescription: "Este plugin podrá usar los permisos descritos aquí" +useStarForReactionFallback: "En caso de que los emojis de reacciones no sean claros, usar en su lugar una estrella" +emailConfig: "Configuración del servidor de correos" +enableEmail: "Activar el envío de correos electrónicos" +emailConfigInfo: "Usar en caso de validación de correo electrónico y pedido de contraseña" +email: "Correo electrónico" +smtpConfig: "Configuración del servidor SMTP" +smtpHost: "Host" +smtpPort: "Puerto" +smtpUser: "Nombre de usuario" +smtpPass: "Contraseña" +emptyToDisableSmtpAuth: "Deje el nombre del usuario y la contraseña en blanco para deshabilitar la autenticación SMTP" +smtpSecure: "Usar SSL/TLS implícito en la conexión SMTP" +smtpSecureInfo: "Apagar cuando se use STARTTLS" +testEmail: "Prueba de envío" _theme: explore: "Explorar temas" install: "Instalar tema" @@ -711,6 +734,7 @@ _widgets: activity: "Actividad" photos: "Fotos" digitalClock: "Reloj digital" + federation: "Federación" _cw: hide: "Ocultar" show: "Ver más" @@ -1164,9 +1188,16 @@ _notification: youReceivedFollowRequest: "Has mandado una solicitud de seguimiento" yourFollowRequestAccepted: "Tu solicitud de seguimiento fue aceptada" youWereInvitedToGroup: "Invitado al grupo" + _types: + follow: "Siguiendo" + mention: "Menciones" + renote: "Renotar" + quote: "Citar" + reaction: "Reacción" _deck: alwaysShowMainColumn: "Siempre mostrar la columna principal" columnAlign: "Alinear columnas" + addColumn: "Agregar columna" _columns: widgets: "Widgets" notifications: "Notificaciones" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index b055f59678..b4aa5513a7 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -104,6 +104,8 @@ unblockConfirm: "Êtes-vous sûr·e de vouloir débloquer ce compte ?" suspendConfirm: "Êtes-vous sûr·e de vouloir suspendre ce compte ?" unsuspendConfirm: "Êtes-vous sûr·e de vouloir annuler la suspension de ce compte ?" selectList: "Sélectionner une liste" +selectAntenna: "Sélectionner une antenne" +selectWidget: "Sélectionner un widget" customEmojis: "Émojis personnalisés" emoji: "Émoji" emojiName: "Nom de l’émoji" @@ -442,7 +444,7 @@ remote: "Distant" total: "Total" weekOverWeekChanges: "Diff hebdo" dayOverDayChanges: "Diff quotidien" -accessibility: "Accessibilité" +appearance: "Aspect" clinetSettings: "Paramètres du client" accountSettings: "Paramètres du compte" promotion: "Promu" @@ -522,8 +524,25 @@ expandTweet: "Étendre le tweet" themeEditor: "Éditeur de thèmes" description: "Description" author: "Auteur·rice" +leaveConfirm: "Vous avez des modifications non-sauvegardées. Voulez-vous les ignorer ?" manage: "Gestion" plugins: "Extensions" +pluginInstallWarn: "N’installez que des extensions provenant de sources de confiance." +deck: "Deck" +undeck: "Quitter le deck" +useBlurEffectForModal: "Utiliser un effet de flou pour les modals" +generateAccessToken: "Générer un jeton d'accès" +permission: "Autorisations " +enableAll: "Tout activer" +disableAll: "Tout désactiver" +tokenRequested: "Autoriser l'accès au compte" +pluginTokenRequestedDescription: "Ce plugin pourra utiliser les autorisations définies ici." +notificationType: "Type de notifications" +edit: "Editer" +emailConfig: "Configuration du serveur email" +smtpHost: "Hôte" +smtpUser: "Nom d’utilisateur·rice" +smtpPass: "Mot de passe" _theme: explore: "Explorer les thèmes" install: "Installer un thème" @@ -536,10 +555,12 @@ _theme: base: "Base" defaultValue: "Valeur par défaut" color: "Couleur" + key: "Clé " func: "Fonction" argument: "Argument" alpha: "Transparence" darken: "Assombrir" + importInfo: "Vous pouvez importer un thème vers l’éditeur de thèmes en saisissant son code ici." keys: bg: "Arrière-plan" fg: "Texte" @@ -549,10 +570,15 @@ _theme: shadow: "Ombre" header: "Entête" navBg: "Fond de la barre latérale" + navFg: "Texte de la barre latérale" + link: "Lien" hashtag: "Hashtags" mention: "Mentionner" + mentionMe: "Mentions (Moi)" renote: "Renote" divider: "Séparateur" + infoWarnFg: "Texte d’avertissement" + badge: "Badge" messageBg: "Arrière plan de la discussion" _sfx: note: "Nouvelle note" @@ -667,6 +693,8 @@ _widgets: rss: "Lecteur de flux RSS" activity: "Activité" photos: "Photos" + digitalClock: "Horloge numérique" + federation: "Fédération" _cw: hide: "Masquer" show: "Afficher plus …" @@ -1120,9 +1148,16 @@ _notification: youReceivedFollowRequest: "Vous avez reçu une demande d’abonnement" yourFollowRequestAccepted: "Votre demande d’abonnement a été accepté" youWereInvitedToGroup: "Invité au groupe" + _types: + follow: "Abonnements" + mention: "Mentionner" + renote: "Renote" + quote: "Citer" + reaction: "Réactions" _deck: alwaysShowMainColumn: "Toujours afficher la colonne principale" columnAlign: "Aligner les colonnes" + addColumn: "Ajouter une colonne" _columns: widgets: "Widgets" notifications: "Notifications" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index ec767aafa9..06b53f26a0 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -104,6 +104,8 @@ unblockConfirm: "ブロック解除しますか?" suspendConfirm: "凍結しますか?" unsuspendConfirm: "解凍しますか?" selectList: "リストを選択" +selectAntenna: "アンテナを選択" +selectWidget: "ウィジェットを選択" customEmojis: "カスタム絵文字" emoji: "絵文字" emojiName: "絵文字名" @@ -535,6 +537,34 @@ enableAll: "全て有効にする" disableAll: "全て無効にする" tokenRequested: "アカウントへのアクセス許可" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。" +notificationType: "通知の種類" +edit: "編集" +useStarForReactionFallback: "リアクション絵文字が不明な場合、代わりに★を使う" +emailConfig: "メールサーバー設定" +enableEmail: "メール配信機能を有効化する" +emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" +email: "メールアドレス" +smtpConfig: "SMTP サーバーの設定" +smtpHost: "ホスト" +smtpPort: "ポート" +smtpUser: "ユーザー名" +smtpPass: "パスワード" +emptyToDisableSmtpAuth: "ユーザー名とパスワードを空欄にすることで、SMTP認証を無効化出来ます" +smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" +smtpSecureInfo: "STARTTLS使用時はオフにします。" +testEmail: "配信テスト" +wordMute: "ワードミュート" +userSaysSomething: "{name}が何かを言いました" +makeActive: "アクティブにする" + +_wordMute: + muteWords: "ミュートするワード" + muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" + muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" + softDescription: "指定した条件のノートをタイムラインから隠します。" + hardDescription: "指定した条件のノートをタイムラインに追加しないようにします。追加されなかったノートは、条件を変更しても除外されたままになります。" + soft: "ソフト" + hard: "ハード" _theme: explore: "テーマを探す" @@ -1211,10 +1241,27 @@ _notification: yourFollowRequestAccepted: "フォローリクエストが承認されました" youWereInvitedToGroup: "グループに招待されました" + _types: + all: "すべて" + follow: "フォロー" + mention: "メンション" + reply: "リプライ" + renote: "Renote" + quote: "引用" + reaction: "リアクション" + pollVote: "投票" + receiveFollowRequest: "フォローリクエスト" + _deck: alwaysShowMainColumn: "常にメインカラムを表示" columnAlign: "カラムの寄せ" addColumn: "カラムを追加" + swapLeft: "左に移動" + swapRight: "右に移動" + swapUp: "上に移動" + swapDown: "下に移動" + stackLeft: "左に重ねる" + popRight: "右に出す" _columns: widgets: "ウィジェット" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 16a344b188..b12c3d45e0 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -295,6 +295,7 @@ proxyRemoteFilesDescription: "この設定を入れると、保存しとらん driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量" driveCapacityPerRemoteAccount: "リモートユーザーひとりあたりのドライブ容量" inMb: "メガバイト単位" +recaptcha: "reCAPTCHA" avoidMultiCaptchaConfirm: "ぎょうさんのCaptchaをつこてしまうと、仲良うせんことがあるんや。他のCaptchaをなおしとこか?別にキャンセルしてもろうたらCaptchaは消されへんで済むけど知らんで。" antennas: "アンテナ" manageAntennas: "アンテナいじる" @@ -352,6 +353,9 @@ notFoundDescription: "指定されたURLに該当するページはあらへん close: "さいなら" joinedGroups: "参加しとるグループ" invites: "来てや" +smtpHost: "ホスト" +smtpUser: "ユーザー名" +smtpPass: "パスワード" _theme: keys: renote: "Renote" @@ -386,6 +390,7 @@ _widgets: notifications: "通知" timeline: "タイムライン" activity: "アクティビティ" + federation: "連合" _cw: show: "もっとあるやろ!" _poll: @@ -434,6 +439,11 @@ _pages: array: "リスト" _notification: youWereFollowed: "フォローされたで" + _types: + follow: "フォロー" + renote: "Renote" + quote: "引用" + reaction: "リアクション" _deck: _columns: notifications: "通知" diff --git a/locales/kab-KAB.yml b/locales/kab-KAB.yml index 4f754f4617..14aaa53e70 100644 --- a/locales/kab-KAB.yml +++ b/locales/kab-KAB.yml @@ -33,6 +33,8 @@ youHaveNoLists: "Ulac ɣur-k·m ula d yiwet n tabdart" remove: "Kkes" userList: "Tibdarin" uiLanguage: "Tutlayt n wegrudem" +smtpUser: "Isem n umseqdac" +smtpPass: "Awal uffir" _theme: keys: mention: "Bder" @@ -80,6 +82,9 @@ _pages: array: "Tibdarin" _notification: youWereFollowed: "Yeṭṭafaṛ-ik·em-id" + _types: + follow: "Ig ṭṭafaṛ" + mention: "Bder" _deck: _columns: notifications: "Ilɣuyen" diff --git a/locales/kn-IN.yml b/locales/kn-IN.yml index 49368dde9b..7a3b162aca 100644 --- a/locales/kn-IN.yml +++ b/locales/kn-IN.yml @@ -54,6 +54,8 @@ driveFileDeleteConfirm: "\"{name}\" ಕಡತವನ್ನು ಅಳಿಸಲು unfollowConfirm: "{name}ಅನ್ನು ಹಿಂಬಾಲಿಸದಿರುವುದೇ?" instances: "ನಿದರ್ಶನ" remove: "ಅಳಿಸು" +smtpUser: "ಬಳಕೆಹೆಸರು" +smtpPass: "ಗುಪ್ತಪದ" _sfx: notification: "ಅಧಿಸೂಚನೆಗಳು" _widgets: diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index f7ffa4de67..51e4c618e0 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -442,7 +442,6 @@ remote: "리모트" total: "합계" weekOverWeekChanges: "지난주보다" dayOverDayChanges: "어제보다" -accessibility: "접근성" clinetSettings: "클라이언트 설정" accountSettings: "계정 설정" promotion: "프로모션" @@ -528,6 +527,9 @@ plugins: "플러그인" pluginInstallWarn: "신뢰할 수 없는 플러그인은 설치하지 마십시오." deck: "덱" undeck: "덱 해제" +smtpHost: "호스트" +smtpUser: "유저명" +smtpPass: "비밀번호" _theme: explore: "테마 찾아보기" install: "테마 설치" @@ -665,6 +667,8 @@ _widgets: rss: "RSS 리더" activity: "활동" photos: "사진" + digitalClock: "디지털 시계" + federation: "연합" _cw: hide: "숨기기" show: "더 보기" @@ -1116,8 +1120,15 @@ _notification: youReceivedFollowRequest: "새로운 팔로우 요청이 있습니다" yourFollowRequestAccepted: "팔로우 요청이 수락되었습니다" youWereInvitedToGroup: "그룹에 초대되었습니다" + _types: + follow: "팔로잉" + mention: "멘션" + renote: "Renote" + quote: "인용" + reaction: "리액션" _deck: _columns: + widgets: "위젯" notifications: "알림" tl: "타임라인" antenna: "안테나" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 1a2c922ce8..b31a191bee 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -31,6 +31,7 @@ importAndExport: "Импорт / Экспорт" files: "Файл" instances: "Экземпляр" remove: "Удалить" +smtpPass: "Пароль" _sfx: notification: "Уведомления" _widgets: diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index a4291d2e48..d0ad7e8f3a 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -86,8 +86,8 @@ you: "您" clickToShow: "点击以显示" sensitive: "阅读注意" add: "添加" -reaction: "反应" -reactionSettingDescription: "选择您想要固定在反应选择器中的反应。" +reaction: "回应" +reactionSettingDescription: "选择您想要置顶的回应。" rememberNoteVisibility: "记录公开范围" attachCancel: "删除附件" markAsSensitive: "阅读注意" @@ -104,6 +104,7 @@ unblockConfirm: "确定要解除屏蔽吗?" suspendConfirm: "要冻结吗?" unsuspendConfirm: "要解除冻结吗?" selectList: "选择列表" +selectWidget: "选择小工具" customEmojis: "自定义Emoji" emoji: "表情符号" emojiName: "Emoji 名称" @@ -364,7 +365,7 @@ resetPassword: "重置密码" newPasswordIs: "新的密码是「{password}」" autoReloadWhenDisconnected: "断开连接时自动重新加载" autoNoteWatch: "自动关注帖子" -autoNoteWatchDescription: "让您能够收到关于「反应」和回复其他用户的帖子的通知。" +autoNoteWatchDescription: "让您能够收到关于「回应」和回复其他用户的帖子的通知。" reduceUiAnimation: "减少UI动画" share: "分享" notFound: "未找到" @@ -442,7 +443,7 @@ remote: "远程" total: "总计" weekOverWeekChanges: "与前一周相比" dayOverDayChanges: "与前一日相比" -accessibility: "辅助功能" +appearance: "外观" clinetSettings: "客户端设置" accountSettings: "账户设置" promotion: "推广" @@ -528,6 +529,29 @@ plugins: "插件" pluginInstallWarn: "请不要安装不明来源的插件" deck: "Deck" undeck: "取消Deck" +useBlurEffectForModal: "模态框使用模糊效果" +generateAccessToken: "生成访问令牌" +permission: "权限" +enableAll: "启用全部" +disableAll: "禁用全部" +tokenRequested: "允许访问账户" +pluginTokenRequestedDescription: "此插件将能够拥有此处设置的权限" +notificationType: "通知类型" +edit: "编辑" +useStarForReactionFallback: "如果回应的颜文字未知,则使用★作为代替" +emailConfig: "邮件服务器设置" +enableEmail: "启用发送邮件功能" +emailConfigInfo: "用于确认电子邮件和密码重置" +email: "邮件地址" +smtpConfig: "SMTP服务器设置" +smtpHost: "主机名" +smtpPort: "端口" +smtpUser: "用户名" +smtpPass: "密码" +emptyToDisableSmtpAuth: "用户名和密码留空可以禁用SMTP验证" +smtpSecure: "在 SMTP 连接中使用隐式 SSL / TLS" +smtpSecureInfo: "使用STARTTLS时关闭。" +testEmail: "邮件发送测试" _theme: explore: "寻找主题" install: "安装主题" @@ -536,7 +560,7 @@ _theme: installed: "{name} 已安装" alreadyInstalled: "此主题已经安装" invalid: "主题格式错误" - make: "主题制作" + make: "制作主题" base: "基于" addConstant: "添加常量" constant: "常量" @@ -574,7 +598,7 @@ _theme: mention: "提及" mentionMe: "提及" renote: "转发" - modalBg: "模块背景" + modalBg: "模态框背景" divider: "分割线" scrollbarHandle: "滚动条" scrollbarHandleHover: "滚动条(悬停)" @@ -596,6 +620,8 @@ _theme: wallpaperOverlay: "壁纸叠加层" badge: "徽章" messageBg: "聊天背景" + accentDarken: "强调色(暗)" + accentLighten: "强调色(亮)" fgHighlighted: "高亮显示文本" _sfx: note: "帖子" @@ -638,8 +664,8 @@ _tutorial: step5_3: "要关注其他用户,请单击他的头像,然后在他的个人资料上按下“关注”按钮。" step5_4: "如果用户的名称旁边有锁定图标,则该用户需要手动批准您的关注请求。" step6_1: "现在,您将可以在时间线上看到其他用户的帖子。" - step6_2: "您还可以在其他人的帖子上进行「反应」,以快速做出简单回复。" - step6_3: "在他人的贴子上按下「+」图标,即可选择想要的表情来进行「反应」。" + step6_2: "您还可以在其他人的帖子上进行「回应」,以快速做出简单回复。" + step6_3: "在他人的贴子上按下「+」图标,即可选择想要的表情来进行「回应」。" step7_1: "对Misskey基本操作的简单介绍,到此结束了。 辛苦了!" step7_2: "如果你想了解更多有关Misskey的信息,请参见{help}。" step7_3: "接下来,享受Misskey带来的乐趣吧🚀" @@ -711,6 +737,7 @@ _widgets: activity: "活动" photos: "照片" digitalClock: "数字时钟" + federation: "联邦宇宙" _cw: hide: "隐藏" show: "查看更多" @@ -1164,11 +1191,18 @@ _notification: youReceivedFollowRequest: "您有新的关注请求" yourFollowRequestAccepted: "您的关注请求已通过" youWereInvitedToGroup: "您有新的群组邀请" + _types: + follow: "关注中" + mention: "提及" + renote: "转发" + quote: "引用" + reaction: "回应" _deck: alwaysShowMainColumn: "总是显示主列" columnAlign: "列对齐" + addColumn: "添加列" _columns: - widgets: "小部件" + widgets: "小工具" notifications: "通知" tl: "时间线" antenna: "天线" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index be4b4302dd..bebe468e7f 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -4,13 +4,13 @@ introMisskey: "歡迎! Misskey是一個開源的去中心化的社群網站。 monthAndDay: "{month}月 {day}日" search: "搜尋" notifications: "通知" -username: "用戶名" +username: "使用名稱" password: "密碼" -fetchingAsApObject: "從Fediverse尋找中..." -ok: "OK" +fetchingAsApObject: "從 Fediverse 查詢中..." +ok: "確定" gotIt: "知道了" cancel: "取消" -enterUsername: "輸入用戶名" +enterUsername: "輸入使用者名稱" renotedBy: "由{user}轉發" noNotes: "貼文不可用。" noNotifications: "沒有通知" @@ -24,11 +24,11 @@ loggingIn: "登入中" logout: "登出" signup: "註冊" uploading: "上傳中" -save: "保存" -users: "用戶" -addUser: "新增用戶" +save: "儲存" +users: "使用者" +addUser: "新增使用者" favorite: "收藏" -favorites: "收藏" +favorites: "已加星號" unfavorite: "取消收藏" pin: "置頂" unpin: "取消置頂" @@ -82,7 +82,7 @@ unrenote: "取消轉發貼文" quote: "引用" pinnedNote: "已置頂的貼文" you: "您" -clickToShow: "點擊查看" +clickToShow: "按一下以顯示" sensitive: "敏感內容" add: "新增" reaction: "反應" @@ -92,8 +92,8 @@ attachCancel: "移除附件" markAsSensitive: "標記為敏感內容" unmarkAsSensitive: "取消標記為敏感內容" enterFileName: "請輸入檔案名稱" -mute: "禁言" -unmute: "解除禁言" +mute: "消音" +unmute: "解除消音" block: "封鎖" unblock: "解除封鎖" suspend: "凍結" @@ -108,42 +108,48 @@ emoji: "表情符號" emojiName: "表情符號名稱" emojiUrl: "表情符號URL" addEmoji: "新增表情符號" -settingGuide: "推介設定" +settingGuide: "推薦設定" flagAsBot: "此帳戶是Bot" flagAsCat: "此帳戶是Cat" autoAcceptFollowed: "自動許可追隨" -addAcount: "新增帳戶" +addAcount: "新增帳號" loginFailed: "登入失敗" general: "一般" -wallpaper: "壁紙" +wallpaper: "桌布" setWallpaper: "設定桌布" -removeWallpaper: "移除壁紙" +removeWallpaper: "移除桌布" searchWith: "搜尋: {q}" -youHaveNoLists: "你沒有任何清單" -followConfirm: "你真的要追隨{name}嗎?" +youHaveNoLists: "沒有任何清單" +followConfirm: "你真的要關注{name}嗎?" +proxyAccount: "代理帳號" host: "主機" -selectUser: "選擇用戶" -recipient: "收件人" +selectUser: "選取使用者" +recipient: "發送至" annotation: "註解" -federation: "整合" +federation: "聯邦宇宙" instances: "實例" latestStatus: "最後狀態" storageUsage: "已使用容量" charts: "圖表" perHour: "每小時" perDay: "每日" +blockThisInstance: "封鎖此實例" operations: "操作" software: "軟體" version: "版本" +metadata: "元資料(Metadata)" withNFiles: "{n}個檔案" monitor: "監視器" +jobQueue: "佇列" cpuAndMemory: "CPU及記憶體用量" network: "網路" +disk: "硬碟" instanceInfo: "實例資訊" statistics: "統計" clearQueue: "清除佇列" clearQueueConfirmTitle: "確定要清除佇列嗎?" clearCachedFiles: "清除快取資料" +clearCachedFilesConfirm: "確定要清除緩存資料嗎?" blockedInstances: "已封鎖的實例" blockedInstancesDescription: "請逐行輸入需要封鎖的實例。已封鎖的實例將無法與本實例進行通訊。" muteAndBlock: "禁言 / 封鎖" @@ -153,14 +159,15 @@ noUsers: "無用戶" editProfile: "編輯個人檔案" noteDeleteConfirm: "確定刪除此貼文嗎?" pinLimitExceeded: "不能再置頂更多的貼文了" -intro: "Misskey安裝作業完成!請創立管理員用戶" +intro: "Misskey 部署完成!請開設管理員帳號!" done: "完成" processing: "處理中" preview: "預覽" default: "預設" noCustomEmojis: "沒有表情符號" customEmojisOfRemote: "來自其他實例的表情符號" -federating: "整合檢索中" +noJobs: "沒有任務" +federating: "整合搜索中" blocked: "已封鎖" suspended: "已凍結" all: "全部" @@ -175,12 +182,13 @@ security: "安全性" retypedNotMatch: "不相符的輸入內容" currentPassword: "現在的密碼" newPassword: "新的密碼" -newPasswordRetype: "新的密碼(再輸入一次)" +newPasswordRetype: "新的密碼(再輸入一次)" attachFile: "添加附件" more: "更多!" featured: "精選" -usernameOrUserId: "用戶名或用戶ID" -noSuchUser: "用戶不存在" +usernameOrUserId: "使用者名稱或使用者 ID" +noSuchUser: "使用者不存在" +lookup: "查詢" announcements: "公告" imageUrl: "圖片URL" remove: "刪除" @@ -373,6 +381,7 @@ passwordMatched: "密碼一致" passwordNotMatched: "密碼不一致" signinFailed: "登入失敗。 請檢查用戶名和密碼。" uiLanguage: "介面語言" +youHaveNoGroups: "找不到群組" tags: "標籤" fontSize: "字體大小" total: "合計" @@ -389,11 +398,17 @@ install: "安裝" uninstall: "解除安裝" lastUsedDate: "最後上線日期" state: "狀態" +ascendingOrder: "昇冪" +descendingOrder: "降冪" +scratchpad: "暫存記憶體" output: "輸出" deleteAllFiles: "刪除所有檔案" deleteAllFilesConfirm: "要删除所有檔案吗?" userSilenced: "該用戶已被禁言。" deletedNote: "已删除的貼文" +smtpHost: "主機" +smtpUser: "使用名稱" +smtpPass: "密碼" _theme: func: "函数" keys: @@ -469,6 +484,7 @@ _widgets: rss: "RSS閱讀器" activity: "動態" photos: "照片" + federation: "聯邦宇宙" _cw: show: "瀏覽更多" files: "{count} 個檔案" @@ -481,10 +497,10 @@ _visibility: followers: "追隨者" _profile: name: "名稱" - username: "用戶名" + username: "使用名稱" _exportOrImport: followingList: "追隨中" - muteList: "禁言" + muteList: "消音" blockingList: "封鎖" userLists: "清單" _instanceCharts: @@ -657,6 +673,12 @@ _notification: youGotPoll: "{name}已投票" youWereFollowed: "您有新的追隨者" yourFollowRequestAccepted: "您的追隨請求已通過" + _types: + follow: "追隨中" + mention: "提及" + renote: "轉發貼文" + quote: "引用" + reaction: "反應" _deck: _columns: notifications: "通知" diff --git a/migration/1595676934834-instance-icon-url.ts b/migration/1595676934834-instance-icon-url.ts new file mode 100644 index 0000000000..c75370f174 --- /dev/null +++ b/migration/1595676934834-instance-icon-url.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class instanceIconUrl1595676934834 implements MigrationInterface { + name = 'instanceIconUrl1595676934834' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "instance" ADD "iconUrl" character varying(256) DEFAULT null`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "iconUrl"`); + } + +} diff --git a/migration/1595771249699-word-mute.ts b/migration/1595771249699-word-mute.ts new file mode 100644 index 0000000000..1a9114d921 --- /dev/null +++ b/migration/1595771249699-word-mute.ts @@ -0,0 +1,30 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class wordMute1595771249699 implements MigrationInterface { + name = 'wordMute1595771249699' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`CREATE TABLE "muted_note" ("id" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_897e2eff1c0b9b64e55ca1418a4" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_70ab9786313d78e4201d81cdb8" ON "muted_note" ("noteId") `); + await queryRunner.query(`CREATE INDEX "IDX_d8e07aa18c2d64e86201601aec" ON "muted_note" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a8c6bfd637d3f1d67a27c48e27" ON "muted_note" ("noteId", "userId") `); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "enableWordMute" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "mutedWords" jsonb NOT NULL DEFAULT '[]'`); + await queryRunner.query(`CREATE INDEX "IDX_3befe6f999c86aff06eb0257b4" ON "user_profile" ("enableWordMute") `); + await queryRunner.query(`ALTER TABLE "muted_note" ADD CONSTRAINT "FK_70ab9786313d78e4201d81cdb89" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "muted_note" ADD CONSTRAINT "FK_d8e07aa18c2d64e86201601aec1" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "muted_note" DROP CONSTRAINT "FK_d8e07aa18c2d64e86201601aec1"`); + await queryRunner.query(`ALTER TABLE "muted_note" DROP CONSTRAINT "FK_70ab9786313d78e4201d81cdb89"`); + await queryRunner.query(`DROP INDEX "IDX_3befe6f999c86aff06eb0257b4"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mutedWords"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "enableWordMute"`); + await queryRunner.query(`DROP INDEX "IDX_a8c6bfd637d3f1d67a27c48e27"`); + await queryRunner.query(`DROP INDEX "IDX_d8e07aa18c2d64e86201601aec"`); + await queryRunner.query(`DROP INDEX "IDX_70ab9786313d78e4201d81cdb8"`); + await queryRunner.query(`DROP TABLE "muted_note"`); + } + +} diff --git a/migration/1595782306083-word-mute2.ts b/migration/1595782306083-word-mute2.ts new file mode 100644 index 0000000000..d68c12740e --- /dev/null +++ b/migration/1595782306083-word-mute2.ts @@ -0,0 +1,18 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class wordMute21595782306083 implements MigrationInterface { + name = 'wordMute21595782306083' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`CREATE TYPE "muted_note_reason_enum" AS ENUM('word', 'manual', 'spam', 'other')`); + await queryRunner.query(`ALTER TABLE "muted_note" ADD "reason" "muted_note_reason_enum" NOT NULL`); + await queryRunner.query(`CREATE INDEX "IDX_636e977ff90b23676fb5624b25" ON "muted_note" ("reason") `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`DROP INDEX "IDX_636e977ff90b23676fb5624b25"`); + await queryRunner.query(`ALTER TABLE "muted_note" DROP COLUMN "reason"`); + await queryRunner.query(`DROP TYPE "muted_note_reason_enum"`); + } + +} diff --git a/package.json b/package.json index 050276ceee..3820be3e7c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "misskey", "author": "syuilo <syuilotan@yahoo.co.jp>", - "version": "12.42.0", + "version": "12.45.0", "codename": "indigo", "repository": { "type": "git", @@ -47,7 +47,7 @@ "@koa/multer": "3.0.0", "@koa/router": "9.0.1", "@sinonjs/fake-timers": "6.0.1", - "@syuilo/aiscript": "0.8.0", + "@syuilo/aiscript": "0.10.0", "@types/bcryptjs": "2.4.2", "@types/bull": "3.14.0", "@types/cbor": "5.0.0", @@ -204,6 +204,7 @@ "random-seed": "0.3.0", "randomcolor": "0.5.4", "ratelimiter": "3.4.1", + "re2": "1.15.4", "recaptcha-promise": "0.1.3", "reconnecting-websocket": "4.4.0", "redis": "3.0.2", diff --git a/src/client/components/deck/antenna-column.vue b/src/client/components/deck/antenna-column.vue index 46426367fe..891f59e37d 100644 --- a/src/client/components/deck/antenna-column.vue +++ b/src/client/components/deck/antenna-column.vue @@ -4,7 +4,7 @@ <fa :icon="faSatellite"/><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <x-timeline ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/> + <x-timeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/> </x-column> </template> @@ -33,7 +33,6 @@ export default defineComponent({ data() { return { - menu: null, faSatellite }; }, @@ -47,28 +46,36 @@ export default defineComponent({ created() { this.menu = [{ icon: faCog, - text: this.$t('antenna'), - action: async () => { - const antennas = await this.$root.api('antennas/list'); - this.$root.dialog({ - title: this.$t('antenna'), - type: null, - select: { - items: antennas.map(x => ({ - value: x, text: x.name - })) - }, - showCancelButton: true - }).then(({ canceled, result: antenna }) => { - if (canceled) return; - this.column.antennaId = antenna.id; - this.$store.commit('deviceUser/updateDeckColumn', this.column); - }); - } + text: this.$t('selectAntenna'), + action: this.setAntenna }]; }, + mounted() { + if (this.column.antennaId == null) { + this.setAntenna(); + } + }, + methods: { + async setAntenna() { + const antennas = await this.$root.api('antennas/list'); + const { canceled, result: antenna } = await this.$root.dialog({ + title: this.$t('selectAntenna'), + type: null, + select: { + items: antennas.map(x => ({ + value: x, text: x.name + })), + default: this.column.antennaId + }, + showCancelButton: true + }); + if (canceled) return; + Vue.set(this.column, 'antennaId', antenna.id); + this.$store.commit('deviceUser/updateDeckColumn', this.column); + }, + focus() { (this.$refs.timeline as any).focus(); } diff --git a/src/client/components/deck/column.vue b/src/client/components/deck/column.vue index 9dd74849cb..39b50912e2 100644 --- a/src/client/components/deck/column.vue +++ b/src/client/components/deck/column.vue @@ -150,37 +150,37 @@ export default defineComponent({ } }, null, { icon: faArrowLeft, - text: this.$t('swap-left'), + text: this.$t('_deck.swapLeft'), action: () => { this.$store.commit('deviceUser/swapLeftDeckColumn', this.column.id); } }, { icon: faArrowRight, - text: this.$t('swap-right'), + text: this.$t('_deck.swapRight'), action: () => { this.$store.commit('deviceUser/swapRightDeckColumn', this.column.id); } }, this.isStacked ? { icon: faArrowUp, - text: this.$t('swap-up'), + text: this.$t('_deck.swapUp'), action: () => { this.$store.commit('deviceUser/swapUpDeckColumn', this.column.id); } } : undefined, this.isStacked ? { icon: faArrowDown, - text: this.$t('swap-down'), + text: this.$t('_deck.swapDown'), action: () => { this.$store.commit('deviceUser/swapDownDeckColumn', this.column.id); } } : undefined, null, { icon: faWindowRestore, - text: this.$t('stack-left'), + text: this.$t('_deck.stackLeft'), action: () => { this.$store.commit('deviceUser/stackLeftDeckColumn', this.column.id); } }, this.isStacked ? { icon: faWindowMaximize, - text: this.$t('pop-right'), + text: this.$t('_deck.popRight'), action: () => { this.$store.commit('deviceUser/popRightDeckColumn', this.column.id); } diff --git a/src/client/components/deck/list-column.vue b/src/client/components/deck/list-column.vue index 8aa860e571..9dfceb4c78 100644 --- a/src/client/components/deck/list-column.vue +++ b/src/client/components/deck/list-column.vue @@ -46,7 +46,7 @@ export default defineComponent({ created() { this.menu = [{ icon: faCog, - text: this.$t('list'), + text: this.$t('selectList'), action: this.setList }]; }, @@ -61,7 +61,7 @@ export default defineComponent({ async setList() { const lists = await this.$root.api('users/lists/list'); const { canceled, result: list } = await this.$root.dialog({ - title: this.$t('list'), + title: this.$t('selectList'), type: null, select: { items: lists.map(x => ({ diff --git a/src/client/components/deck/notifications-column.vue b/src/client/components/deck/notifications-column.vue index 19ce324eac..b1e0db8769 100644 --- a/src/client/components/deck/notifications-column.vue +++ b/src/client/components/deck/notifications-column.vue @@ -45,14 +45,14 @@ export default defineComponent({ this.menu = [{ icon: faCog, - text: this.$t('@.notification-type'), + text: this.$t('notificationType'), action: () => { this.$root.dialog({ - title: this.$t('@.notification-type'), + title: this.$t('notificationType'), type: null, select: { items: ['all', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest'].map(x => ({ - value: x, text: this.$t('@.notification-types.' + x) + value: x, text: this.$t(`_notification._types.${x}`) })) default: this.column.notificationType, }, diff --git a/src/client/components/deck/widgets-column.vue b/src/client/components/deck/widgets-column.vue index 2836e8a243..15b4824530 100644 --- a/src/client/components/deck/widgets-column.vue +++ b/src/client/components/deck/widgets-column.vue @@ -5,9 +5,12 @@ <div class="wtdtxvec"> <template v-if="edit"> <header> - <select v-model="widgetAdderSelected" @change="addWidget"> - <option v-for="widget in widgets" :value="widget" :key="widget">{{ widget }}</option> - </select> + <mk-select v-model="widgetAdderSelected" style="margin-bottom: var(--margin)"> + <template #label>{{ $t('selectWidget') }}</template> + <option v-for="widget in widgets" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option> + </mk-select> + <mk-button inline @click="addWidget" primary><fa :icon="faPlus"/> {{ $t('add') }}</mk-button> + <mk-button inline @click="edit = false">{{ $t('close') }}</mk-button> </header> <x-draggable :list="column.widgets" @@ -15,7 +18,7 @@ @sort="onWidgetSort" > <div v-for="widget in column.widgets" class="customize-container" :key="widget.id" @click="widgetFunc(widget.id)"> - <button class="remove _button" @click="removeWidget(widget)"><fa :icon="faTimes"/></button> + <button class="remove _button" @click.prevent.stop="removeWidget(widget)"><fa :icon="faTimes"/></button> <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :column="column"/> </div> </x-draggable> @@ -29,7 +32,9 @@ import { defineComponent } from 'vue'; import * as XDraggable from 'vuedraggable'; import { v4 as uuid } from 'uuid'; -import { faWindowMaximize, faTimes, faCog } from '@fortawesome/free-solid-svg-icons'; +import { faWindowMaximize, faTimes, faCog, faPlus } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '../../components/ui/select.vue'; +import MkButton from '../../components/ui/button.vue'; import XColumn from './column.vue'; import { widgets } from '../../widgets'; @@ -37,6 +42,8 @@ export default defineComponent({ components: { XColumn, XDraggable, + MkSelect, + MkButton, }, props: { @@ -56,7 +63,7 @@ export default defineComponent({ menu: null, widgetAdderSelected: null, widgets, - faWindowMaximize, faTimes + faWindowMaximize, faTimes, faPlus }; }, @@ -80,6 +87,8 @@ export default defineComponent({ }, addWidget() { + if (this.widgetAdderSelected == null) return; + this.$store.commit('deviceUser/addDeckWidget', { id: this.column.id, widget: { diff --git a/src/client/components/form-window.vue b/src/client/components/form-window.vue index d518b6f32d..c6dbfc211a 100644 --- a/src/client/components/form-window.vue +++ b/src/client/components/form-window.vue @@ -5,10 +5,22 @@ </template> <div class="xkpnjxcv"> <label v-for="item in Object.keys(form).filter(item => !form[item].hidden)" :key="item"> - <mk-input v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"><span v-text="form[item].label || item"></span></mk-input> - <mk-input v-else-if="form[item].type === 'string' && !item.multiline" v-model="values[item]" type="text"><span v-text="form[item].label || item"></span></mk-input> - <mk-textarea v-else-if="form[item].type === 'string' && item.multiline" v-model="values[item]"><span v-text="form[item].label || item"></span></mk-textarea> - <mk-switch v-else-if="form[item].type === 'boolean'" v-model="values[item]"><span v-text="form[item].label || item"></span></mk-switch> + <mk-input v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> + <span v-text="form[item].label || item"></span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </mk-input> + <mk-input v-else-if="form[item].type === 'string' && !item.multiline" v-model="values[item]" type="text"> + <span v-text="form[item].label || item"></span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </mk-input> + <mk-textarea v-else-if="form[item].type === 'string' && item.multiline" v-model="values[item]"> + <span v-text="form[item].label || item"></span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </mk-textarea> + <mk-switch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> + <span v-text="form[item].label || item"></span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </mk-switch> </label> </div> </x-window> @@ -48,7 +60,7 @@ export default defineComponent({ created() { for (const item in this.form) { - Vue.set(this.values, item, this.form[item].default || null); + Vue.set(this.values, item, this.form[item].hasOwnProperty('default') ? this.form[item].default : null); } }, diff --git a/src/client/widgets/trends.chart.vue b/src/client/components/mini-chart.vue similarity index 100% rename from src/client/widgets/trends.chart.vue rename to src/client/components/mini-chart.vue diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 885fd764ab..77dd8c39a8 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -1,7 +1,8 @@ <template> <div class="note _panel" - v-show="!isDeleted && !hideThisNote" + v-if="!muted" + v-show="!isDeleted" :tabindex="!isDeleted ? '-1' : null" :class="{ renote: isRenote }" v-hotkey="keymap" @@ -34,19 +35,19 @@ </div> </div> <article class="article"> - <mk-avatar class="avatar" :user="appearNote.user" v-once/> + <mk-avatar class="avatar" :user="appearNote.user"/> <div class="main"> <x-note-header class="header" :note="appearNote" :mini="true"/> - <div class="body" v-if="appearNote.deletedAt == null" ref="noteBody"> + <div class="body" ref="noteBody"> <p v-if="appearNote.cw != null" class="cw"> - <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" v-once/> + <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> <x-cw-button v-model="showContent" :note="appearNote"/> </p> <div class="content" v-show="appearNote.cw == null || showContent"> <div class="text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> <router-link class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><fa :icon="faReply"/></router-link> - <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" v-once/> + <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> <a class="rp" v-if="appearNote.renote != null">RN:</a> </div> <div class="files" v-if="appearNote.files.length > 0"> @@ -57,7 +58,7 @@ <div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div> </div> </div> - <footer v-if="appearNote.deletedAt == null" class="footer"> + <footer class="footer"> <x-reactions-viewer :note="appearNote" ref="reactionsViewer"/> <button @click="reply()" class="button _button"> <template v-if="appearNote.reply"><fa :icon="faReplyAll"/></template> @@ -80,11 +81,17 @@ <fa :icon="faEllipsisH"/> </button> </footer> - <div class="deleted" v-if="appearNote.deletedAt != null">{{ $t('deleted') }}</div> </div> </article> <x-sub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> </div> +<div v-else class="_panel muted" @click="muted = false"> + <i18n-t path="userSaysSomething" tag="small"> + <router-link class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId" place="name"> + <mk-user-name :user="appearNote.user"/> + </router-link> + </i18n-t> +</div> </template> <script lang="ts"> @@ -106,9 +113,16 @@ import pleaseLogin from '../scripts/please-login'; import { focusPrev, focusNext } from '../scripts/focus'; import { url } from '../config'; import copyToClipboard from '../scripts/copy-to-clipboard'; +import { checkWordMute } from '../scripts/check-word-mute'; +import { utils } from '@syuilo/aiscript'; import { userPage } from '../filters/user'; export default defineComponent({ + model: { + prop: 'note', + event: 'updated' + }, + components: { XSub, XNoteHeader, @@ -143,7 +157,8 @@ export default defineComponent({ conversation: [], replies: [], showContent: false, - hideThisNote: false, + isDeleted: false, + muted: false, noteBody: this.$refs.noteBody, faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug }; @@ -187,10 +202,6 @@ export default defineComponent({ return this.isRenote ? this.note.renote : this.note; }, - isDeleted(): boolean { - return this.appearNote.deletedAt != null || this.note.deletedAt != null; - }, - isMyNote(): boolean { return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId); }, @@ -232,11 +243,22 @@ export default defineComponent({ } }, - created() { + async created() { if (this.$store.getters.isSignedIn) { this.connection = this.$root.stream; } + // plugin + if (this.$store.state.noteViewInterruptors.length > 0) { + let result = this.note; + for (const interruptor of this.$store.state.noteViewInterruptors) { + result = utils.valToJs(await interruptor.handler(JSON.parse(JSON.stringify(result)))); + } + this.$emit('updated', Object.freeze(result)); + } + + this.muted = await checkWordMute(this.appearNote, this.$store.state.i, this.$store.state.settings.mutedWords); + if (this.detail) { this.$root.api('notes/children', { noteId: this.appearNote.id, @@ -262,7 +284,7 @@ export default defineComponent({ this.connection.on('_connected_', this.onStreamConnected); } - this.noteBody = this.$refs.noteBody + this.noteBody = this.$refs.noteBody; }, beforeDestroy() { @@ -274,11 +296,24 @@ export default defineComponent({ }, methods: { + updateAppearNote(v) { + this.$emit('updated', Object.freeze(this.isRenote ? { + ...this.note, + renote: { + ...this.note.renote, + ...v + } + } : { + ...this.note, + ...v + })); + }, + readPromo() { (this as any).$root.api('promo/read', { noteId: this.appearNote.id }); - this.hideThisNote = true; + this.isDeleted = true; }, capture(withHandler = false) { @@ -310,67 +345,88 @@ export default defineComponent({ case 'reacted': { const reaction = body.reaction; + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + if (body.emoji) { const emojis = this.appearNote.emojis || []; if (!emojis.includes(body.emoji)) { - emojis.push(body.emoji); - Vue.set(this.appearNote, 'emojis', emojis); + n.emojis = [...emojis, body.emoji]; } } - if (this.appearNote.reactions == null) { - Vue.set(this.appearNote, 'reactions', {}); - } - - if (this.appearNote.reactions[reaction] == null) { - Vue.set(this.appearNote.reactions, reaction, 0); - } + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; // Increment the count - this.appearNote.reactions[reaction]++; + n.reactions = { + ...this.appearNote.reactions, + [reaction]: currentCount + 1 + }; - if (body.userId == this.$store.state.i.id) { - Vue.set(this.appearNote, 'myReaction', reaction); + if (body.userId === this.$store.state.i.id) { + n.myReaction = reaction; } + + this.updateAppearNote(n); break; } case 'unreacted': { const reaction = body.reaction; - if (this.appearNote.reactions == null) { - return; - } + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; - if (this.appearNote.reactions[reaction] == null) { - return; - } + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; // Decrement the count - if (this.appearNote.reactions[reaction] > 0) this.appearNote.reactions[reaction]--; + n.reactions = { + ...this.appearNote.reactions, + [reaction]: Math.max(0, currentCount - 1) + }; - if (body.userId == this.$store.state.i.id) { - Vue.set(this.appearNote, 'myReaction', null); + if (body.userId === this.$store.state.i.id) { + n.myReaction = null; } + + this.updateAppearNote(n); break; } case 'pollVoted': { const choice = body.choice; - this.appearNote.poll.choices[choice].votes++; - if (body.userId == this.$store.state.i.id) { - Vue.set(this.appearNote.poll.choices[choice], 'isVoted', true); - } + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + n.poll = { + ...this.appearNote.poll, + choices: { + ...this.appearNote.poll.choices, + [choice]: { + ...this.appearNote.poll.choices[choice], + votes: this.appearNote.poll.choices[choice].votes + 1, + ...(body.userId === this.$store.state.i.id ? { + isVoted: true + } : {}) + } + } + }; + + this.updateAppearNote(n); break; } case 'deleted': { - Vue.set(this.appearNote, 'deletedAt', body.deletedAt); - Vue.set(this.appearNote, 'renote', null); - this.appearNote.text = null; - this.appearNote.fileIds = []; - this.appearNote.poll = null; - this.appearNote.cw = null; + this.isDeleted = true; break; } } @@ -639,7 +695,7 @@ export default defineComponent({ this.$root.api('notes/delete', { noteId: this.note.id }); - Vue.set(this.note, 'deletedAt', new Date()); + this.isDeleted = true; } }], source: this.$refs.renoteTime, @@ -928,10 +984,6 @@ export default defineComponent({ } } } - - > .deleted { - opacity: 0.7; - } } } @@ -998,4 +1050,10 @@ export default defineComponent({ } } } + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} </style> diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue index 12ad1f0668..3f07fb50d5 100644 --- a/src/client/components/notes.vue +++ b/src/client/components/notes.vue @@ -15,7 +15,7 @@ </div> <x-list ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> - <x-note :note="note" :detail="detail" :key="note._featuredId_ || note._prId_ || note.id"/> + <x-note :note="note" @updated="updated(note, $event)" :detail="detail" :key="note._featuredId_ || note._prId_ || note.id"/> </x-list> <div v-show="more && !reversed" style="margin-top: var(--margin);"> @@ -62,14 +62,15 @@ export default defineComponent({ default: false }, - extract: { + prop: { + type: String, required: false } }, computed: { notes(): any[] { - return this.extract ? this.extract(this.items) : this.items; + return this.prop ? this.items.map(item => item[this.prop]) : this.items; }, reversed(): boolean { @@ -78,6 +79,15 @@ export default defineComponent({ }, methods: { + updated(oldValue, newValue) { + const i = this.notes.findIndex(n => n === oldValue); + if (this.prop) { + Vue.set(this.items[i], this.prop, newValue); + } else { + Vue.set(this.items, i, newValue); + } + }, + focus() { this.$refs.notes.focus(); } diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue index 8bee88e785..9dff1d4243 100644 --- a/src/client/components/notifications.vue +++ b/src/client/components/notifications.vue @@ -1,7 +1,7 @@ <template> <div class="mfcuwfyp"> <x-list class="notifications" :items="items" v-slot="{ item: notification }"> - <x-note v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" :key="notification.id"/> + <x-note v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @updated="noteUpdated(notification.note, $event)" :key="notification.id"/> <x-notification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> </x-list> @@ -75,11 +75,20 @@ export default defineComponent({ this.$root.stream.send('readNotification', { id: notification.id }); - - notification.isRead = true; } - this.prepend(notification); + this.prepend({ + ...notification, + isRead: document.visibilityState === 'visible' + }); + }, + + noteUpdated(oldValue, newValue) { + const i = this.items.findIndex(n => n.note === oldValue); + Vue.set(this.items, i, { + ...this.items[i], + note: newValue + }); }, } }); diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue index 5260a9ad59..641701e964 100644 --- a/src/client/components/post-form.vue +++ b/src/client/components/post-form.vue @@ -69,6 +69,7 @@ import getAcct from '../../misc/acct/render'; import { formatTimeString } from '../../misc/format-time-string'; import { selectDriveFile } from '../scripts/select-drive-file'; import { noteVisibilities } from '../../types'; +import { utils } from '@syuilo/aiscript'; export default defineComponent({ components: { @@ -533,9 +534,8 @@ export default defineComponent({ localStorage.setItem('drafts', JSON.stringify(data)); }, - post() { - this.posting = true; - this.$root.api('notes/create', { + async post() { + let data = { text: this.text == '' ? undefined : this.text, fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, replyId: this.reply ? this.reply.id : undefined, @@ -546,7 +546,17 @@ export default defineComponent({ visibility: this.visibility, visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, viaMobile: this.$root.isMobile - }).then(data => { + }; + + // plugin + if (this.$store.state.notePostInterruptors.length > 0) { + for (const interruptor of this.$store.state.notePostInterruptors) { + data = utils.valToJs(await interruptor.handler(JSON.parse(JSON.stringify(data)))); + } + } + + this.posting = true; + this.$root.api('notes/create', data).then(() => { this.clear(); this.deleteDraft(); this.$emit('posted'); diff --git a/src/client/components/tab.vue b/src/client/components/tab.vue new file mode 100644 index 0000000000..824f150840 --- /dev/null +++ b/src/client/components/tab.vue @@ -0,0 +1,46 @@ +<template> +<div class="pxhvhrfw" v-size="[{ max: 500 }]"> + <button v-for="item in items" class="_button" @click="$emit('input', item.value)" :class="{ active: value === item.value }" :key="item.value"><fa v-if="item.icon" :icon="item.icon" class="icon"/>{{ item.label }}</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + items: { + type: Array, + required: true, + }, + value: { + required: true, + }, + }, +}); +</script> + +<style lang="scss" scoped> +.pxhvhrfw { + display: flex; + + > button { + flex: 1; + padding: 11px 8px 8px 8px; + border-bottom: solid 3px transparent; + + &.active { + color: var(--accent); + border-bottom-color: var(--accent); + } + + > .icon { + margin-right: 6px; + } + } + + &.max-width_500px { + font-size: 80%; + } +} +</style> diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue index 252025f48a..083cf479f6 100644 --- a/src/client/components/timeline.vue +++ b/src/client/components/timeline.vue @@ -47,8 +47,7 @@ export default defineComponent({ created() { const prepend = note => { - const _note = JSON.parse(JSON.stringify(note)); // deepcopy - (this.$refs.tl as any).prepend(_note); + (this.$refs.tl as any).prepend(note); this.$emit('note'); diff --git a/src/client/init.ts b/src/client/init.ts index 472965fac6..03eee450c7 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -223,7 +223,7 @@ stream.on('emojiAdded', data => { //store.commit('instance/set', ); }); -for (const plugin of store.state.deviceUser.plugins) { +for (const plugin of store.state.deviceUser.plugins.filter(p => p.active)) { console.info('Plugin installed:', plugin.name, 'v' + plugin.version); const aiscript = new AiScript(createPluginEnv(app, { diff --git a/src/client/pages/favorites.vue b/src/client/pages/favorites.vue index 339e34442e..c235ce5ae3 100644 --- a/src/client/pages/favorites.vue +++ b/src/client/pages/favorites.vue @@ -2,7 +2,7 @@ <div> <portal to="icon"><fa :icon="faStar"/></portal> <portal to="title">{{ $t('favorites') }}</portal> - <x-notes :pagination="pagination" :detail="true" :extract="items => items.map(item => item.note)" @before="before()" @after="after()"/> + <x-notes :pagination="pagination" :detail="true" :prop="'note'" @before="before()" @after="after()"/> </div> </template> diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue index a61793737a..0ba5d763b0 100644 --- a/src/client/pages/instance/index.vue +++ b/src/client/pages/instance/index.vue @@ -437,7 +437,7 @@ export default defineComponent({ }, onStatsLog(statsLog) { - for (const stats of statsLog.reverse()) { + for (const stats of [...statsLog].reverse()) { this.onStats(stats); } }, diff --git a/src/client/pages/instance/queue.queue.vue b/src/client/pages/instance/queue.queue.vue index ff6a389794..e020cfc095 100644 --- a/src/client/pages/instance/queue.queue.vue +++ b/src/client/pages/instance/queue.queue.vue @@ -170,7 +170,7 @@ export default defineComponent({ }, onStatsLog(statsLog) { - for (const stats of statsLog.reverse()) { + for (const stats of [...statsLog].reverse()) { this.onStats(stats); } }, diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue index 7d36a0a314..4048fea6cf 100644 --- a/src/client/pages/instance/settings.vue +++ b/src/client/pages/instance/settings.vue @@ -28,6 +28,9 @@ <mk-switch v-model="enableGlobalTimeline" @change="save()">{{ $t('enableGlobalTimeline') }}</mk-switch> <mk-info>{{ $t('disablingTimelinesInfo') }}</mk-info> </div> + <div class="_content"> + <mk-switch v-model="useStarForReactionFallback" @change="save()">{{ $t('useStarForReactionFallback') }}</mk-switch> + </div> </section> <section class="_card info"> @@ -74,6 +77,29 @@ </div> </section> + <section class="_card"> + <div class="_title"><fa :icon="faEnvelope" /> {{ $t('emailConfig') }}</div> + <div class="_content"> + <mk-switch v-model="enableEmail" @change="save()">{{ $t('enableEmail') }}<template #desc>{{ $t('emailConfigInfo') }}</template></mk-switch> + <mk-input v-model="email" type="email" :disabled="!enableEmail">{{ $t('email') }}</mk-input> + <div><b>{{ $t('smtpConfig') }}</b></div> + <div class="_inputs"> + <mk-input v-model="smtpHost" :disabled="!enableEmail">{{ $t('smtpHost') }}</mk-input> + <mk-input v-model="smtpPort" type="number" :disabled="!enableEmail">{{ $t('smtpPort') }}</mk-input> + </div> + <div class="_inputs"> + <mk-input v-model="smtpUser" :disabled="!enableEmail">{{ $t('smtpUser') }}</mk-input> + <mk-input v-model="smtpPass" type="password" :disabled="!enableEmail">{{ $t('smtpPass') }}</mk-input> + </div> + <mk-info>{{ $t('emptyToDisableSmtpAuth') }}</mk-info> + <mk-switch v-model="smtpSecure" :disabled="!enableEmail">{{ $t('smtpSecure') }}<template #desc>{{ $t('smtpSecureInfo') }}</template></mk-switch> + <div> + <mk-button :disabled="!enableEmail" inline @click="testEmail()">{{ $t('testEmail') }}</mk-button> + <mk-button :disabled="!enableEmail" primary inline @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </div> + </section> + <section class="_card"> <div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div> <div class="_content"> @@ -195,12 +221,19 @@ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> </div> </section> + <section class="_card"> + <div class="_title"><fa :icon="faArchway" /> Summaly Proxy</div> + <div class="_content"> + <mk-input v-model="summalyProxy">URL</mk-input> + <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </section> </div> </template> <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; -import { faPencilAlt, faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt } from '@fortawesome/free-solid-svg-icons'; +import { faPencilAlt, faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt, faArchway } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt, faEnvelope } from '@fortawesome/free-regular-svg-icons'; import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; import MkButton from '../../components/ui/button.vue'; @@ -243,7 +276,9 @@ export default defineComponent({ maintainerEmail: null, name: null, description: null, - tosUrl: null, + tosUrl: null as string | null, + enableEmail: false, + email: null, bannerUrl: null, iconUrl: null, maxNoteTextLength: 0, @@ -279,7 +314,14 @@ export default defineComponent({ enableDiscordIntegration: false, discordClientId: null, discordClientSecret: null, - faPencilAlt, faTwitter, faDiscord, faGithub, faShareAlt, faTrashAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faEnvelope, faThumbtack, faUser, faShieldAlt, faKey, faBolt + useStarForReactionFallback: false, + smtpSecure: false, + smtpHost: '', + smtpPort: 0, + smtpUser: '', + smtpPass: '', + summalyProxy: '', + faPencilAlt, faTwitter, faDiscord, faGithub, faShareAlt, faTrashAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faEnvelope, faThumbtack, faUser, faShieldAlt, faKey, faBolt, faArchway } }, @@ -295,6 +337,8 @@ export default defineComponent({ this.tosUrl = this.meta.tosUrl; this.bannerUrl = this.meta.bannerUrl; this.iconUrl = this.meta.iconUrl; + this.enableEmail = this.meta.enableEmail; + this.email = this.meta.email; this.maintainerName = this.meta.maintainerName; this.maintainerEmail = this.meta.maintainerEmail; this.maxNoteTextLength = this.meta.maxNoteTextLength; @@ -337,6 +381,13 @@ export default defineComponent({ this.enableDiscordIntegration = this.meta.enableDiscordIntegration; this.discordClientId = this.meta.discordClientId; this.discordClientSecret = this.meta.discordClientSecret; + this.useStarForReactionFallback = this.meta.useStarForReactionFallback; + this.smtpSecure = this.meta.smtpSecure; + this.smtpHost = this.meta.smtpHost; + this.smtpPort = this.meta.smtpPort; + this.smtpUser = this.meta.smtpUser; + this.smtpPass = this.meta.smtpPass; + this.summalyProxy = this.meta.summalyProxy; if (this.proxyAccountId) { this.$root.api('users/show', { userId: this.proxyAccountId }).then(proxyAccount => { @@ -412,6 +463,24 @@ export default defineComponent({ }); }, + async testEmail() { + this.$root.api('admin/send-email', { + to: this.maintainerEmail, + subject: 'Test email', + text: 'Yo' + }).then(x => { + this.$root.dialog({ + type: 'success', + splash: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + save(withDialog = false) { this.$root.api('admin/update-meta', { name: this.name, @@ -461,6 +530,15 @@ export default defineComponent({ enableDiscordIntegration: this.enableDiscordIntegration, discordClientId: this.discordClientId, discordClientSecret: this.discordClientSecret, + enableEmail: this.enableEmail, + email: this.email, + smtpSecure: this.smtpSecure, + smtpHost: this.smtpHost, + smtpPort: this.smtpPort, + smtpUser: this.smtpUser, + smtpPass: this.smtpPass, + summalyProxy: this.summalyProxy, + useStarForReactionFallback: this.useStarForReactionFallback, }).then(() => { this.$store.dispatch('instance/fetch'); if (withDialog) { diff --git a/src/client/pages/my-settings/index.vue b/src/client/pages/my-settings/index.vue index a5b9e5c013..94e6ec53af 100644 --- a/src/client/pages/my-settings/index.vue +++ b/src/client/pages/my-settings/index.vue @@ -27,6 +27,7 @@ <x-import-export/> <x-drive/> <x-mute-block/> + <x-word-mute/> <x-security/> <x-2fa/> <x-integration/> @@ -47,6 +48,7 @@ import XImportExport from './import-export.vue'; import XDrive from './drive.vue'; import XReactionSetting from './reaction.vue'; import XMuteBlock from './mute-block.vue'; +import XWordMute from './word-mute.vue'; import XSecurity from './security.vue'; import X2fa from './2fa.vue'; import XIntegration from './integration.vue'; @@ -68,6 +70,7 @@ export default defineComponent({ XDrive, XReactionSetting, XMuteBlock, + XWordMute, XSecurity, X2fa, XIntegration, diff --git a/src/client/pages/my-settings/word-mute.vue b/src/client/pages/my-settings/word-mute.vue new file mode 100644 index 0000000000..6b2a372f0b --- /dev/null +++ b/src/client/pages/my-settings/word-mute.vue @@ -0,0 +1,77 @@ +<template> +<section class="_card"> + <div class="_title"><fa :icon="faCommentSlash"/> {{ $t('wordMute') }}</div> + <div class="_content _noPad"> + <mk-tab v-model="tab" :items="[{ label: $t('_wordMute.soft'), value: 'soft' }, { label: $t('_wordMute.hard'), value: 'hard' }]"/> + </div> + <div class="_content" v-show="tab === 'soft'"> + <mk-info>{{ $t('_wordMute.softDescription') }}</mk-info> + <mk-textarea v-model="softMutedWords"> + <span>{{ $t('_wordMute.muteWords') }}</span> + <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> + </mk-textarea> + </div> + <div class="_content" v-show="tab === 'hard'"> + <mk-info>{{ $t('_wordMute.hardDescription') }}</mk-info> + <mk-textarea v-model="hardMutedWords"> + <span>{{ $t('_wordMute.muteWords') }}</span> + <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> + </mk-textarea> + </div> + <div class="_footer"> + <mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '../../components/ui/button.vue'; +import MkTextarea from '../../components/ui/textarea.vue'; +import MkTab from '../../components/tab.vue'; +import MkInfo from '../../components/ui/info.vue'; + +export default Vue.extend({ + components: { + MkButton, + MkTextarea, + MkTab, + MkInfo, + }, + + data() { + return { + tab: 'soft', + softMutedWords: '', + hardMutedWords: '', + changed: false, + faCommentSlash, faSave, + } + }, + + watch: { + softMutedWords() { + this.changed = true; + }, + hardMutedWords() { + this.changed = true; + }, + }, + + created() { + this.softMutedWords = this.$store.state.settings.mutedWords.map(x => x.join(' ')).join('\n'); + this.hardMutedWords = this.$store.state.i.mutedWords.map(x => x.join(' ')).join('\n'); + }, + + methods: { + async save() { + this.$store.dispatch('settings/set', { key: 'mutedWords', value: this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')) }); + await this.$root.api('i/update', { + mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')), + }); + this.changed = false; + }, + } +}); +</script> diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue index 3e58c7c937..458d2d8c98 100644 --- a/src/client/pages/note.vue +++ b/src/client/pages/note.vue @@ -14,7 +14,7 @@ <hr v-if="showNext"/> <mk-remote-caution v-if="note.user.host != null" :href="note.url || note.uri" style="margin-bottom: var(--margin)"/> - <x-note :note="note" :key="note.id" :detail="true"/> + <x-note v-model="note" :key="note.id" :detail="true"/> <button class="_panel _button" v-if="hasPrev && !showPrev" @click="showPrev = true" style="margin: var(--margin) auto 0 auto;"><fa :icon="faChevronDown"/></button> <hr v-if="showPrev"/> diff --git a/src/client/pages/pages.vue b/src/client/pages/pages.vue index 832cb05748..94d8744fc1 100644 --- a/src/client/pages/pages.vue +++ b/src/client/pages/pages.vue @@ -3,24 +3,20 @@ <portal to="icon"><fa :icon="faStickyNote"/></portal> <portal to="title">{{ $t('pages') }}</portal> - <mk-container :body-togglable="true"> - <template #header><fa :icon="faEdit" fixed-width/>{{ $t('_pages.my') }}</template> - <div class="rknalgpo my"> - <mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button> - <mk-pagination :pagination="myPagesPagination" #default="{items}"> - <mk-page-preview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> - </mk-pagination> - </div> - </mk-container> + <mk-tab v-model="tab" :items="[{ label: $t('_pages.my'), value: 'my', icon: faEdit }, { label: $t('_pages.liked'), value: 'liked', icon: faHeart }]"/> - <mk-container :body-togglable="true"> - <template #header><fa :icon="faHeart" fixed-width/>{{ $t('_pages.liked') }}</template> - <div class="rknalgpo"> - <mk-pagination :pagination="likedPagesPagination" #default="{items}"> - <mk-page-preview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/> - </mk-pagination> - </div> - </mk-container> + <div class="rknalgpo my" v-if="tab === 'my'"> + <mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button> + <mk-pagination :pagination="myPagesPagination" #default="{items}"> + <mk-page-preview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> + </mk-pagination> + </div> + + <div class="rknalgpo" v-if="tab === 'liked'"> + <mk-pagination :pagination="likedPagesPagination" #default="{items}"> + <mk-page-preview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/> + </mk-pagination> + </div> </div> </template> @@ -31,14 +27,15 @@ import { faStickyNote, faHeart } from '@fortawesome/free-regular-svg-icons'; import MkPagePreview from '../components/page-preview.vue'; import MkPagination from '../components/ui/pagination.vue'; import MkButton from '../components/ui/button.vue'; -import MkContainer from '../components/ui/container.vue'; +import MkTab from '../components/tab.vue'; export default defineComponent({ components: { - MkPagePreview, MkPagination, MkButton, MkContainer + MkPagePreview, MkPagination, MkButton, MkTab }, data() { return { + tab: 'my', myPagesPagination: { endpoint: 'i/pages', limit: 5, diff --git a/src/client/pages/preferences/plugins.vue b/src/client/pages/preferences/plugins.vue index ca6ff72c61..2eb5725dd5 100644 --- a/src/client/pages/preferences/plugins.vue +++ b/src/client/pages/preferences/plugins.vue @@ -18,6 +18,9 @@ <option v-for="x in $store.state.deviceUser.plugins" :value="x.id" :key="x.id">{{ x.name }}</option> </mk-select> <template v-if="selectedPlugin"> + <div style="margin: -8px 0 8px 0;"> + <mk-switch :value="selectedPlugin.active" @change="changeActive(selectedPlugin, $event)">{{ $t('makeActive') }}</mk-switch> + </div> <div class="_keyValue"> <div>{{ $t('version') }}:</div> <div>{{ selectedPlugin.version }}</div> @@ -44,11 +47,13 @@ import { defineComponent } from 'vue'; import { AiScript, parse } from '@syuilo/aiscript'; import { serialize } from '@syuilo/aiscript/built/serializer'; +import { v4 as uuid } from 'uuid'; import { faPlug, faSave, faTrashAlt, faFolderOpen, faDownload, faCog } from '@fortawesome/free-solid-svg-icons'; import MkButton from '../../components/ui/button.vue'; import MkTextarea from '../../components/ui/textarea.vue'; import MkSelect from '../../components/ui/select.vue'; import MkInfo from '../../components/ui/info.vue'; +import MkSwitch from '../../components/ui/switch.vue'; export default defineComponent({ components: { @@ -56,6 +61,7 @@ export default defineComponent({ MkTextarea, MkSelect, MkInfo, + MkSwitch, }, data() { @@ -101,8 +107,8 @@ export default defineComponent({ }); return; } - const { id, name, version, author, description, permissions, config } = data; - if (id == null || name == null || version == null || author == null) { + const { name, version, author, description, permissions, config } = data; + if (name == null || version == null || author == null) { this.$root.dialog({ type: 'error', text: 'Required property not found :(' @@ -128,8 +134,9 @@ export default defineComponent({ }); this.$store.commit('deviceUser/installPlugin', { + id: uuid(), meta: { - id, name, version, author, description, permissions, config + name, version, author, description, permissions, config }, token, ast: serialize(ast) @@ -171,6 +178,17 @@ export default defineComponent({ config: result }); + this.$nextTick(() => { + location.reload(); + }); + }, + + changeActive(plugin, active) { + this.$store.commit('deviceUser/changePluginActive', { + id: plugin.id, + active: active + }); + this.$nextTick(() => { location.reload(); }); diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue index ec342f1624..33bfb26b59 100644 --- a/src/client/pages/user/index.vue +++ b/src/client/pages/user/index.vue @@ -83,7 +83,7 @@ <router-view :user="user"></router-view> <template v-if="$route.name == 'user'"> <div class="pins"> - <x-note v-for="note in user.pinnedNotes" class="note" :note="note" :key="note.id" :detail="true" :pinned="true"/> + <x-note v-for="note in user.pinnedNotes" class="note" :note="note" @updated="pinnedNoteUpdated(note, $event)" :key="note.id" :detail="true" :pinned="true"/> </div> <mk-container :body-togglable="true" class="content"> <template #header><fa :icon="faImage"/>{{ $t('images') }}</template> @@ -213,6 +213,11 @@ export default defineComponent({ banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; }, + pinnedNoteUpdated(oldValue, newValue) { + const i = this.user.pinnedNotes.findIndex(n => n === oldValue); + Vue.set(this.user.pinnedNotes, i, newValue); + }, + number, userPage diff --git a/src/client/scripts/aiscript/api.ts b/src/client/scripts/aiscript/api.ts index 1919794d83..c91b9578d5 100644 --- a/src/client/scripts/aiscript/api.ts +++ b/src/client/scripts/aiscript/api.ts @@ -15,9 +15,9 @@ export function createAiScriptEnv(vm, opts) { text: text.value, }); }), - 'Mk:confirm': values.FN_NATIVE(async ([title, text]) => { + 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { const confirm = await vm.$root.dialog({ - type: 'warning', + type: type ? type.value : 'question', showCancelButton: true, title: title.value, text: text.value, @@ -46,12 +46,13 @@ export function createAiScriptEnv(vm, opts) { // TODO: vm引数は消せる(各種操作がstoreに移動し、かつstoreが複数ファイルで共有されるようになったため) export function createPluginEnv(vm, opts) { const config = new Map(); - for (const [k, v] of Object.entries(opts.plugin.config)) { + for (const [k, v] of Object.entries(opts.plugin.config || {})) { config.set(k, jsToVal(opts.plugin.configData[k] || v.default)); } return { ...createAiScriptEnv(vm, { ...opts, token: opts.plugin.token }), + //#region Deprecated 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { vm.$store.commit('registerPostFormAction', { pluginId: opts.plugin.id, title: title.value, handler }); }), @@ -61,6 +62,25 @@ export function createPluginEnv(vm, opts) { 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { vm.$store.commit('registerNoteAction', { pluginId: opts.plugin.id, title: title.value, handler }); }), + //#endregion + 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + vm.$store.commit('registerPostFormAction', { pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { + vm.$store.commit('registerUserAction', { pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { + vm.$store.commit('registerNoteAction', { pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { + vm.$store.commit('registerNoteViewInterruptor', { pluginId: opts.plugin.id, handler }); + }), + 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { + vm.$store.commit('registerNotePostInterruptor', { pluginId: opts.plugin.id, handler }); + }), + 'Plugin:open_url': values.FN_NATIVE(([url]) => { + window.open(url.value, '_blank'); + }), 'Plugin:config': values.OBJ(config), }; } diff --git a/src/client/scripts/check-word-mute.ts b/src/client/scripts/check-word-mute.ts new file mode 100644 index 0000000000..3b1fa75b1e --- /dev/null +++ b/src/client/scripts/check-word-mute.ts @@ -0,0 +1,26 @@ +export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + const words = mutedWords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (words.length > 0) { + if (note.text == null) return false; + + const matched = words.some(and => + and.every(keyword => { + const regexp = keyword.match(/^\/(.+)\/(.*)$/); + if (regexp) { + return new RegExp(regexp[1], regexp[2]).test(note.text!); + } + return note.text!.includes(keyword); + })); + + if (matched) return true; + } + + return false; +} diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts index 5755cfe611..adcdc6597a 100644 --- a/src/client/scripts/paging.ts +++ b/src/client/scripts/paging.ts @@ -73,10 +73,6 @@ export default (opts) => ({ }, methods: { - updateItem(i, item) { - (this as any).items[i] = item; - }, - reload() { this.items = []; this.init(); @@ -93,6 +89,9 @@ export default (opts) => ({ ...params, limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1, }).then(items => { + for (const item of items) { + Object.freeze(item); + } if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) { items.pop(); this.items = this.pagination.reversed ? [...items].reverse() : items; @@ -129,6 +128,9 @@ export default (opts) => ({ untilId: this.items[this.items.length - 1].id, }), }).then(items => { + for (const item of items) { + Object.freeze(item); + } if (items.length > SECOND_FETCH_LIMIT) { items.pop(); this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); diff --git a/src/client/scripts/stream.ts b/src/client/scripts/stream.ts index 7823c9af7b..86fa90e8fd 100644 --- a/src/client/scripts/stream.ts +++ b/src/client/scripts/stream.ts @@ -109,10 +109,10 @@ export default class Stream extends EventEmitter { } for (const c of connections.filter(c => c != null)) { - c.emit(body.type, body.body); + c.emit(body.type, Object.freeze(body.body)); } } else { - this.emit(type, body); + this.emit(type, Object.freeze(body)); } } diff --git a/src/client/store.ts b/src/client/store.ts index 0e5c9c4e10..09b1372702 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -18,6 +18,7 @@ export const defaultSettings = { pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]', memo: null, reactions: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + mutedWords: [], }; export const defaultDeviceUserSettings = { @@ -44,7 +45,14 @@ export const defaultDeviceUserSettings = { columns: [], layout: [], }, - plugins: [], + plugins: [] as { + id: string; + name: string; + active: boolean; + configData: Record<string, any>; + token: string; + ast: any[]; + }[], }; export const defaultDeviceSettings = { @@ -110,6 +118,8 @@ export const store = createStore({ postFormActions: [], userActions: [], noteActions: [], + noteViewInterruptors: [], + notePostInterruptors: [], }, getters: { @@ -277,6 +287,22 @@ export const store = createStore({ } }); }, + + registerNoteViewInterruptor(state, { pluginId, handler }) { + state.noteViewInterruptors.push({ + handler: (note) => { + return state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); + } + }); + }, + + registerNotePostInterruptor(state, { pluginId, handler }) { + state.notePostInterruptors.push({ + handler: (note) => { + return state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); + } + }); + }, }, actions: { @@ -598,9 +624,11 @@ export const store = createStore({ }, //#endregion - installPlugin(state, { meta, ast, token }) { + installPlugin(state, { id, meta, ast, token }) { state.plugins.push({ ...meta, + id, + active: true, configData: {}, token: token, ast: ast @@ -614,6 +642,10 @@ export const store = createStore({ configPlugin(state, { id, config }) { state.plugins.find(p => p.id === id).configData = config; }, + + changePluginActive(state, { id, active }) { + state.plugins.find(p => p.id === id).active = active; + }, } }, diff --git a/src/client/style.scss b/src/client/style.scss index c3d3cf2233..ab0dcf6220 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -355,6 +355,10 @@ hr { padding: 16px; } + &._noPad { + padding: 0 !important; + } + & + ._content { border-top: solid 1px var(--divider); } diff --git a/src/client/widgets/federation.vue b/src/client/widgets/federation.vue index b99ef1b0aa..7bafb67c8a 100644 --- a/src/client/widgets/federation.vue +++ b/src/client/widgets/federation.vue @@ -5,12 +5,13 @@ <div class="wbrkwalb"> <mk-loading v-if="fetching"/> <transition-group tag="div" name="chart" class="instances" v-else> - <div v-for="instance in instances" :key="instance.id"> - <div class="instance"> - <a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">#{{ instance.host }}</a> - <p>{{ instance.softwareName }} {{ instance.softwareVersion }}</p> + <div v-for="(instance, i) in instances" :key="instance.id" class="instance"> + <img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/> + <div class="body"> + <a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a> + <p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p> </div> - <x-chart class="chart" :src="stat.chart"/> + <mk-mini-chart class="chart" :src="charts[i].requests.received"/> </div> </transition-group> </div> @@ -21,7 +22,7 @@ import { faGlobe } from '@fortawesome/free-solid-svg-icons'; import MkContainer from '../components/ui/container.vue'; import define from './define'; -import XChart from './trends.chart.vue'; +import MkMiniChart from '../components/mini-chart.vue'; export default define({ name: 'federation', @@ -33,11 +34,12 @@ export default define({ }) }).extend({ components: { - MkContainer, XChart + MkContainer, MkMiniChart }, data() { return { instances: [], + charts: [], fetching: true, faGlobe }; @@ -50,14 +52,15 @@ export default define({ clearInterval(this.clock); }, methods: { - fetch() { - this.$root.api('federation/instances', { + async fetch() { + const instances = await this.$root.api('federation/instances', { sort: '+lastCommunicatedAt', limit: 5 - }).then(instances => { - this.instances = instances; - this.fetching = false; }); + const charts = await Promise.all(instances.map(i => this.$root.api('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); + this.instances = instances; + this.charts = charts; + this.fetching = false; } } }); @@ -65,6 +68,9 @@ export default define({ <style lang="scss" scoped> .wbrkwalb { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px; overflow: hidden; @@ -73,13 +79,22 @@ export default define({ transition: transform 1s ease; } - > div { + > .instance { display: flex; align-items: center; padding: 14px 16px; border-bottom: solid 1px var(--divider); - > .instance { + > img { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + object-fit: cover; + border-radius: 4px; + margin-right: 8px; + } + + > .body { flex: 1; overflow: hidden; font-size: 0.9em; @@ -91,14 +106,14 @@ export default define({ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: 18px; + line-height: $bodyTitleHieght; } > p { margin: 0; font-size: 75%; opacity: 0.7; - line-height: 16px; + line-height: $bodyInfoHieght; } } diff --git a/src/client/widgets/trends.vue b/src/client/widgets/trends.vue index d4a4b2d289..b439f91d54 100644 --- a/src/client/widgets/trends.vue +++ b/src/client/widgets/trends.vue @@ -10,7 +10,7 @@ <router-link class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> <p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p> </div> - <x-chart class="chart" :src="stat.chart"/> + <mk-mini-chart class="chart" :src="stat.chart"/> </div> </transition-group> </div> @@ -21,7 +21,7 @@ import { faHashtag } from '@fortawesome/free-solid-svg-icons'; import MkContainer from '../components/ui/container.vue'; import define from './define'; -import XChart from './trends.chart.vue'; +import MkMiniChart from '../components/mini-chart.vue'; export default define({ name: 'hashtags', @@ -33,7 +33,7 @@ export default define({ }) }).extend({ components: { - MkContainer, XChart + MkContainer, MkMiniChart }, data() { return { diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 81fb92f684..6ffc56ee08 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -59,6 +59,7 @@ import { PromoNote } from '../models/entities/promo-note'; import { PromoRead } from '../models/entities/promo-read'; import { program } from '../argv'; import { Relay } from '../models/entities/relay'; +import { MutedNote } from '../models/entities/muted-note'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -151,6 +152,7 @@ export const entities = [ ReversiGame, ReversiMatching, Relay, + MutedNote, ...charts as any ]; diff --git a/src/docs/create-plugin.ja-JP.md b/src/docs/create-plugin.ja-JP.md new file mode 100644 index 0000000000..b543b81507 --- /dev/null +++ b/src/docs/create-plugin.ja-JP.md @@ -0,0 +1,90 @@ +# プラグインの作成 +Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 +ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。 + +## メタデータ +プラグインは、AiScriptのメタデータ埋め込み機能を使って、デフォルトとしてプラグインのメタデータを定義する必要があります。 +メタデータは次のプロパティを含むオブジェクトです。 + +### mame +プラグイン名 + +### author +プラグイン作者 + +### version +プラグインバージョン。数値を指定してください。 + +### description +プラグインの説明 + +### permissions +プラグインが要求する権限。MisskeyAPIにリクエストする際に用いられます。 + +### config +プラグインの設定情報を表すオブジェクト。 +キーに設定名、値に以下のプロパティを含めます。 + +#### type +設定値の種類を表す文字列。以下から選択します。 +string number boolean + +#### label +ユーザーに表示する設定名 + +#### description +設定の説明 + +#### default +設定のデフォルト値 + +## APIリファレンス +AiScript標準で組み込まれているAPIは掲載しません。 + +### Mk:dialog(title text type) +ダイアログを表示します。typeには以下の値が設定できます。 +info success warn error question +省略すると info になります。 + +### Mk:confirm(title text type) +確認ダイアログを表示します。typeには以下の値が設定できます。 +info success warn error question +省略すると question になります。 +ユーザーが"OK"を選択した場合は true を、"キャンセル"を選択した場合は false が返ります。 + +### Mk:api(endpoint params) +Misskey APIにリクエストします。第一引数にエンドポイント名、第二引数にパラメータオブジェクトを渡します。 + +### Mk:save(key value) +任意の値に任意の名前を付けて永続化します。永続化した値は、AiScriptコンテキストが終了しても残り、Mk:loadで読み取ることができます。 + +### Mk:load(key) +Mk:saveで永続化した指定の名前の値を読み取ります。 + +### Plugin:register_post_form_action(title fn) +投稿フォームにアクションを追加します。第一引数にアクション名、第二引数にアクションが選択された際のコールバック関数を渡します。 +コールバック関数には、第一引数に投稿フォームオブジェクトが渡されます。 + +### Plugin:register_note_action(title fn) +ノートメニューに項目を追加します。第一引数に項目名、第二引数に項目が選択された際のコールバック関数を渡します。 +コールバック関数には、第一引数に対象のノートオブジェクトが渡されます。 + +### Plugin:register_user_action(title fn) +ユーザーメニューに項目を追加します。第一引数に項目名、第二引数に項目が選択された際のコールバック関数を渡します。 +コールバック関数には、第一引数に対象のユーザーオブジェクトが渡されます。 + +### Plugin:register_note_view_interruptor(fn) +UIに表示されるノート情報を書き換えます。 +コールバック関数には、第一引数に対象のノートオブジェクトが渡されます。 +コールバック関数の返り値でノートが書き換えられます。 + +### Plugin:register_note_post_interruptor(fn) +ノート投稿時にノート情報を書き換えます。 +コールバック関数には、第一引数に対象のノートオブジェクトが渡されます。 +コールバック関数の返り値でノートが書き換えられます。 + +### Plugin:open_url(url) +第一引数に渡されたURLをブラウザの新しいタブで開きます。 + +### Plugin:config +プラグインの設定が格納されるオブジェクト。プラグイン定義のconfigで設定したキーで値が入ります。 diff --git a/src/misc/app-lock.ts b/src/misc/app-lock.ts index ca2181f879..847299b46d 100644 --- a/src/misc/app-lock.ts +++ b/src/misc/app-lock.ts @@ -21,8 +21,8 @@ export function getApLock(uri: string, timeout = 30 * 1000) { return lock(`ap-object:${uri}`, timeout); } -export function getNodeinfoLock(host: string, timeout = 30 * 1000) { - return lock(`nodeinfo:${host}`, timeout); +export function getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000) { + return lock(`instance:${host}`, timeout); } export function getChartInsertLock(lockKey: string, timeout = 30 * 1000) { diff --git a/src/misc/check-word-mute.ts b/src/misc/check-word-mute.ts new file mode 100644 index 0000000000..5af267d75d --- /dev/null +++ b/src/misc/check-word-mute.ts @@ -0,0 +1,39 @@ +const RE2 = require('re2'); +import { Note } from '../models/entities/note'; +import { User } from '../models/entities/user'; + +type NoteLike = { + userId: Note['userId']; + text: Note['text']; +}; + +type UserLike = { + id: User['id']; +}; + +export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: string[][]): Promise<boolean> { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + const words = mutedWords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (words.length > 0) { + if (note.text == null) return false; + + const matched = words.some(and => + and.every(keyword => { + const regexp = keyword.match(/^\/(.+)\/(.*)$/); + if (regexp) { + return new RE2(regexp[1], regexp[2]).test(note.text!); + } + return note.text!.includes(keyword); + })); + + if (matched) return true; + } + + return false; +} diff --git a/src/misc/fetch.ts b/src/misc/fetch.ts index 358bc25030..7be0e53fd4 100644 --- a/src/misc/fetch.ts +++ b/src/misc/fetch.ts @@ -27,6 +27,27 @@ export async function getJson(url: string, accept = 'application/json, */*', tim return await res.json(); } +export async function getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: HeadersInit) { + const res = await fetch(url, { + headers: Object.assign({ + 'User-Agent': config.userAgent, + Accept: accept + }, headers || {}), + timeout, + agent: getAgentByUrl, + }); + + if (!res.ok) { + throw { + name: `StatusError`, + statusCode: res.status, + message: `${res.status} ${res.statusText}`, + }; + } + + return await res.text(); +} + /** * Get http non-proxy agent */ diff --git a/src/misc/should-mute-this-note.ts b/src/misc/is-muted-user-related.ts similarity index 76% rename from src/misc/should-mute-this-note.ts rename to src/misc/is-muted-user-related.ts index 8f606a2943..6f074bcb90 100644 --- a/src/misc/should-mute-this-note.ts +++ b/src/misc/is-muted-user-related.ts @@ -1,4 +1,4 @@ -export default function(note: any, mutedUserIds: string[]): boolean { +export function isMutedUserRelated(note: any, mutedUserIds: string[]): boolean { if (mutedUserIds.includes(note.userId)) { return true; } diff --git a/src/models/entities/instance.ts b/src/models/entities/instance.ts index fe620887d2..5fedfc0956 100644 --- a/src/models/entities/instance.ts +++ b/src/models/entities/instance.ts @@ -158,6 +158,11 @@ export class Instance { }) public maintainerEmail: string | null; + @Column('varchar', { + length: 256, nullable: true, default: null, + }) + public iconUrl: string | null; + @Column('timestamp with time zone', { nullable: true, }) diff --git a/src/models/entities/muted-note.ts b/src/models/entities/muted-note.ts new file mode 100644 index 0000000000..521876688c --- /dev/null +++ b/src/models/entities/muted-note.ts @@ -0,0 +1,48 @@ +import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Note } from './note'; +import { User } from './user'; +import { id } from '../id'; +import { mutedNoteReasons } from '../../types'; + +@Entity() +@Index(['noteId', 'userId'], { unique: true }) +export class MutedNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The note ID.' + }) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + /** + * ミュートされた理由。 + */ + @Index() + @Column('enum', { + enum: mutedNoteReasons, + comment: 'The reason of the MutedNote.' + }) + public reason: typeof mutedNoteReasons[number]; +} diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts index a89d7364f3..0a6722aace 100644 --- a/src/models/entities/user-profile.ts +++ b/src/models/entities/user-profile.ts @@ -147,6 +147,17 @@ export class UserProfile { }) public integrations: Record<string, any>; + @Index() + @Column('boolean', { + default: false, + }) + public enableWordMute: boolean; + + @Column('jsonb', { + default: [] + }) + public mutedWords: string[][]; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/src/models/index.ts b/src/models/index.ts index e1389e7353..e58d8b551d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -53,6 +53,7 @@ import { PromoNote } from './entities/promo-note'; import { PromoRead } from './entities/promo-read'; import { EmojiRepository } from './repositories/emoji'; import { RelayRepository } from './repositories/relay'; +import { MutedNote } from './entities/muted-note'; export const Announcements = getRepository(Announcement); export const AnnouncementReads = getRepository(AnnouncementRead); @@ -108,3 +109,4 @@ export const AntennaNotes = getRepository(AntennaNote); export const PromoNotes = getRepository(PromoNote); export const PromoReads = getRepository(PromoRead); export const Relays = getCustomRepository(RelayRepository); +export const MutedNotes = getRepository(MutedNote); diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index bbaafc9050..955a70ee60 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -239,6 +239,7 @@ export class UserRepository extends Repository<User> { hasUnreadNotification: this.getHasUnreadNotification(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), integrations: profile!.integrations, + mutedWords: profile!.mutedWords, } : {}), ...(opts.includeSecrets ? { diff --git a/src/queue/processors/deliver.ts b/src/queue/processors/deliver.ts index 16b2f6e29a..cb7587ef81 100644 --- a/src/queue/processors/deliver.ts +++ b/src/queue/processors/deliver.ts @@ -4,7 +4,7 @@ import { registerOrFetchInstanceDoc } from '../../services/register-or-fetch-ins import Logger from '../../services/logger'; import { Instances } from '../../models'; import { instanceChart } from '../../services/chart'; -import { fetchNodeinfo } from '../../services/fetch-nodeinfo'; +import { fetchInstanceMetadata } from '../../services/fetch-instance-metadata'; import { fetchMeta } from '../../misc/fetch-meta'; import { toPuny } from '../../misc/convert-host'; @@ -48,7 +48,7 @@ export default async (job: Bull.Job) => { isNotResponding: false }); - fetchNodeinfo(i); + fetchInstanceMetadata(i); instanceChart.requestSent(i.host, true); }); diff --git a/src/queue/processors/inbox.ts b/src/queue/processors/inbox.ts index 1d35079e9d..b4e8b85a46 100644 --- a/src/queue/processors/inbox.ts +++ b/src/queue/processors/inbox.ts @@ -8,7 +8,7 @@ import { instanceChart } from '../../services/chart'; import { fetchMeta } from '../../misc/fetch-meta'; import { toPuny, extractDbHost } from '../../misc/convert-host'; import { getApId } from '../../remote/activitypub/type'; -import { fetchNodeinfo } from '../../services/fetch-nodeinfo'; +import { fetchInstanceMetadata } from '../../services/fetch-instance-metadata'; import { InboxJobData } from '..'; import DbResolver from '../../remote/activitypub/db-resolver'; import { resolvePerson } from '../../remote/activitypub/models/person'; @@ -126,7 +126,7 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => { isNotResponding: false }); - fetchNodeinfo(i); + fetchInstanceMetadata(i); instanceChart.requestReceived(i.host); }); diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts index a213abf474..5213f872ec 100644 --- a/src/remote/activitypub/models/person.ts +++ b/src/remote/activitypub/models/person.ts @@ -26,7 +26,7 @@ import { validActor } from '../../../remote/activitypub/type'; import { getConnection } from 'typeorm'; import { ensure } from '../../../prelude/ensure'; import { toArray } from '../../../prelude/array'; -import { fetchNodeinfo } from '../../../services/fetch-nodeinfo'; +import { fetchInstanceMetadata } from '../../../services/fetch-instance-metadata'; const logger = apLogger; @@ -204,7 +204,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us registerOrFetchInstanceDoc(host).then(i => { Instances.increment({ id: i.id }, 'usersCount', 1); instanceChart.newUser(i.host); - fetchNodeinfo(i); + fetchInstanceMetadata(i); }); usersChart.update(user!, true); diff --git a/src/server/api/common/generate-muted-note-query.ts b/src/server/api/common/generate-muted-note-query.ts new file mode 100644 index 0000000000..498930476c --- /dev/null +++ b/src/server/api/common/generate-muted-note-query.ts @@ -0,0 +1,13 @@ +import { User } from '../../../models/entities/user'; +import { MutedNotes } from '../../../models'; +import { SelectQueryBuilder } from 'typeorm'; + +export function generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: User) { + const mutedQuery = MutedNotes.createQueryBuilder('muted') + .select('muted.noteId') + .where('muted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/src/server/api/common/generate-mute-query.ts b/src/server/api/common/generate-muted-user-query.ts similarity index 87% rename from src/server/api/common/generate-mute-query.ts rename to src/server/api/common/generate-muted-user-query.ts index 4504d23512..b346f2f0fb 100644 --- a/src/server/api/common/generate-mute-query.ts +++ b/src/server/api/common/generate-muted-user-query.ts @@ -2,7 +2,7 @@ import { User } from '../../../models/entities/user'; import { Mutings } from '../../../models'; import { SelectQueryBuilder, Brackets } from 'typeorm'; -export function generateMuteQuery(q: SelectQueryBuilder<any>, me: User, exclude?: User) { +export function generateMutedUserQuery(q: SelectQueryBuilder<any>, me: User, exclude?: User) { const mutingQuery = Mutings.createQueryBuilder('muting') .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: me.id }); @@ -28,7 +28,7 @@ export function generateMuteQuery(q: SelectQueryBuilder<any>, me: User, exclude? q.setParameters(mutingQuery.getParameters()); } -export function generateMuteQueryForUsers(q: SelectQueryBuilder<any>, me: User) { +export function generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: User) { const mutingQuery = Mutings.createQueryBuilder('muting') .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: me.id }); diff --git a/src/server/api/common/inject-featured.ts b/src/server/api/common/inject-featured.ts index 92e1e3b396..098d20e72d 100644 --- a/src/server/api/common/inject-featured.ts +++ b/src/server/api/common/inject-featured.ts @@ -2,7 +2,7 @@ import rndstr from 'rndstr'; import { Note } from '../../../models/entities/note'; import { User } from '../../../models/entities/user'; import { Notes, UserProfiles, NoteReactions } from '../../../models'; -import { generateMuteQuery } from './generate-mute-query'; +import { generateMutedUserQuery } from './generate-muted-user-query'; import { ensure } from '../../../prelude/ensure'; // TODO: リアクション、Renote、返信などをしたノートは除外する @@ -29,7 +29,7 @@ export async function injectFeatured(timeline: Note[], user?: User | null) { if (user) { query.andWhere('note.userId != :userId', { userId: user.id }); - generateMuteQuery(query, user); + generateMutedUserQuery(query, user); const reactionQuery = NoteReactions.createQueryBuilder('reaction') .select('reaction.noteId') diff --git a/src/server/api/endpoints/antennas/notes.ts b/src/server/api/endpoints/antennas/notes.ts index 54af6c7d3f..402a2758bb 100644 --- a/src/server/api/endpoints/antennas/notes.ts +++ b/src/server/api/endpoints/antennas/notes.ts @@ -4,7 +4,7 @@ import define from '../../define'; import { Antennas, Notes, AntennaNotes } from '../../../../models'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { ApiError } from '../../error'; export const meta = { @@ -62,7 +62,7 @@ export default define(meta, async (ps, user) => { .setParameters(antennaQuery.getParameters()); generateVisibilityQuery(query, user); - generateMuteQuery(query, user); + generateMutedUserQuery(query, user); const notes = await query .take(ps.limit!) diff --git a/src/server/api/endpoints/clips/notes.ts b/src/server/api/endpoints/clips/notes.ts index d4d994d55a..4cd7e8c621 100644 --- a/src/server/api/endpoints/clips/notes.ts +++ b/src/server/api/endpoints/clips/notes.ts @@ -4,7 +4,7 @@ import define from '../../define'; import { Clips, Notes } from '../../../../models'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; export const meta = { tags: ['account', 'notes', 'clips'], @@ -57,7 +57,7 @@ export default define(meta, async (ps, user) => { .setParameters(clipQuery.getParameters()); generateVisibilityQuery(query, user); - generateMuteQuery(query, user); + generateMutedUserQuery(query, user); const notes = await query .take(ps.limit!) diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index 48b5e48fc2..e1889df22d 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -142,7 +142,11 @@ export const meta = { desc: { 'ja-JP': 'ピン留めするページID' } - } + }, + + mutedWords: { + validator: $.optional.arr($.arr($.str)) + }, }, errors: { @@ -193,6 +197,10 @@ export default define(meta, async (ps, user, token) => { if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.mutedWords !== undefined) { + profileUpdates.mutedWords = ps.mutedWords; + profileUpdates.enableWordMute = ps.mutedWords.length > 0; + } if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; diff --git a/src/server/api/endpoints/notes/children.ts b/src/server/api/endpoints/notes/children.ts index e1a5cc1c8f..0875e0f240 100644 --- a/src/server/api/endpoints/notes/children.ts +++ b/src/server/api/endpoints/notes/children.ts @@ -3,7 +3,7 @@ import { ID } from '../../../../misc/cafy-id'; import define from '../../define'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { Brackets } from 'typeorm'; import { Notes } from '../../../../models'; @@ -67,7 +67,7 @@ export default define(meta, async (ps, user) => { .leftJoinAndSelect('note.user', 'user'); generateVisibilityQuery(query, user); - if (user) generateMuteQuery(query, user); + if (user) generateMutedUserQuery(query, user); const notes = await query.take(ps.limit!).getMany(); diff --git a/src/server/api/endpoints/notes/featured.ts b/src/server/api/endpoints/notes/featured.ts index 0dc705de7a..4dda7d0edb 100644 --- a/src/server/api/endpoints/notes/featured.ts +++ b/src/server/api/endpoints/notes/featured.ts @@ -1,6 +1,6 @@ import $ from 'cafy'; import define from '../../define'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { Notes } from '../../../../models'; export const meta = { @@ -51,7 +51,7 @@ export default define(meta, async (ps, user) => { .andWhere(`note.visibility = 'public'`) .leftJoinAndSelect('note.user', 'user'); - if (user) generateMuteQuery(query, user); + if (user) generateMutedUserQuery(query, user); let notes = await query .orderBy('note.score', 'DESC') diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts index 26b0cb0f5a..5e61c17841 100644 --- a/src/server/api/endpoints/notes/global-timeline.ts +++ b/src/server/api/endpoints/notes/global-timeline.ts @@ -5,11 +5,12 @@ import { fetchMeta } from '../../../../misc/fetch-meta'; import { ApiError } from '../../error'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { Notes } from '../../../../models'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { activeUsersChart } from '../../../../services/chart'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { injectPromo } from '../../common/inject-promo'; import { injectFeatured } from '../../common/inject-featured'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; export const meta = { desc: { @@ -82,7 +83,8 @@ export default define(meta, async (ps, user) => { .leftJoinAndSelect('note.user', 'user'); generateRepliesQuery(query, user); - if (user) generateMuteQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateMutedNoteQuery(query, user); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts index b0a73d1d7d..fab4e9f4e5 100644 --- a/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -7,11 +7,12 @@ import { makePaginationQuery } from '../../common/make-pagination-query'; import { Followings, Notes } from '../../../../models'; import { Brackets } from 'typeorm'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { activeUsersChart } from '../../../../services/chart'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { injectPromo } from '../../common/inject-promo'; import { injectFeatured } from '../../common/inject-featured'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; export const meta = { desc: { @@ -132,7 +133,8 @@ export default define(meta, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); - generateMuteQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts index a74dc3b15c..38ec1d4727 100644 --- a/src/server/api/endpoints/notes/local-timeline.ts +++ b/src/server/api/endpoints/notes/local-timeline.ts @@ -4,7 +4,7 @@ import define from '../../define'; import { fetchMeta } from '../../../../misc/fetch-meta'; import { ApiError } from '../../error'; import { Notes } from '../../../../models'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; import { activeUsersChart } from '../../../../services/chart'; @@ -12,6 +12,7 @@ import { Brackets } from 'typeorm'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { injectPromo } from '../../common/inject-promo'; import { injectFeatured } from '../../common/inject-featured'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; export const meta = { desc: { @@ -100,7 +101,8 @@ export default define(meta, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); - if (user) generateMuteQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateMutedNoteQuery(query, user); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts index a478d89c07..8a9d295d38 100644 --- a/src/server/api/endpoints/notes/mentions.ts +++ b/src/server/api/endpoints/notes/mentions.ts @@ -4,7 +4,7 @@ import define from '../../define'; import read from '../../../../services/note/read'; import { Notes, Followings } from '../../../../models'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { Brackets } from 'typeorm'; @@ -66,7 +66,7 @@ export default define(meta, async (ps, user) => { .leftJoinAndSelect('note.user', 'user'); generateVisibilityQuery(query, user); - generateMuteQuery(query, user); + generateMutedUserQuery(query, user); if (ps.visibility) { query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); diff --git a/src/server/api/endpoints/notes/renotes.ts b/src/server/api/endpoints/notes/renotes.ts index 1a6f66b368..31c24f294a 100644 --- a/src/server/api/endpoints/notes/renotes.ts +++ b/src/server/api/endpoints/notes/renotes.ts @@ -4,7 +4,7 @@ import define from '../../define'; import { getNote } from '../../common/getters'; import { ApiError } from '../../error'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { Notes } from '../../../../models'; @@ -71,7 +71,7 @@ export default define(meta, async (ps, user) => { .leftJoinAndSelect('note.user', 'user'); generateVisibilityQuery(query, user); - if (user) generateMuteQuery(query, user); + if (user) generateMutedUserQuery(query, user); const renotes = await query.take(ps.limit!).getMany(); diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts index 3bf16804ef..9fad74c78e 100644 --- a/src/server/api/endpoints/notes/replies.ts +++ b/src/server/api/endpoints/notes/replies.ts @@ -4,7 +4,7 @@ import define from '../../define'; import { Notes } from '../../../../models'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; export const meta = { desc: { @@ -62,7 +62,7 @@ export default define(meta, async (ps, user) => { .leftJoinAndSelect('note.user', 'user'); generateVisibilityQuery(query, user); - if (user) generateMuteQuery(query, user); + if (user) generateMutedUserQuery(query, user); const timeline = await query.take(ps.limit!).getMany(); diff --git a/src/server/api/endpoints/notes/search-by-tag.ts b/src/server/api/endpoints/notes/search-by-tag.ts index b30fe732e2..446beb32dd 100644 --- a/src/server/api/endpoints/notes/search-by-tag.ts +++ b/src/server/api/endpoints/notes/search-by-tag.ts @@ -3,7 +3,7 @@ import { ID } from '../../../../misc/cafy-id'; import define from '../../define'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { Notes } from '../../../../models'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; import { Brackets } from 'typeorm'; import { safeForSql } from '../../../../misc/safe-for-sql'; @@ -97,7 +97,7 @@ export default define(meta, async (ps, me) => { .leftJoinAndSelect('note.user', 'user'); generateVisibilityQuery(query, me); - if (me) generateMuteQuery(query, me); + if (me) generateMutedUserQuery(query, me); if (ps.tag) { if (!safeForSql(ps.tag)) return; diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts index 602c1a71f5..2c75d2a55a 100644 --- a/src/server/api/endpoints/notes/search.ts +++ b/src/server/api/endpoints/notes/search.ts @@ -7,7 +7,7 @@ import { ID } from '../../../../misc/cafy-id'; import config from '../../../../config'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; export const meta = { desc: { @@ -69,7 +69,7 @@ export default define(meta, async (ps, me) => { .leftJoinAndSelect('note.user', 'user'); generateVisibilityQuery(query, me); - if (me) generateMuteQuery(query, me); + if (me) generateMutedUserQuery(query, me); const notes = await query.take(ps.limit!).getMany(); diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts index d60136a9ca..657739820b 100644 --- a/src/server/api/endpoints/notes/timeline.ts +++ b/src/server/api/endpoints/notes/timeline.ts @@ -4,12 +4,13 @@ import define from '../../define'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { Notes, Followings } from '../../../../models'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { activeUsersChart } from '../../../../services/chart'; import { Brackets } from 'typeorm'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { injectPromo } from '../../common/inject-promo'; import { injectFeatured } from '../../common/inject-featured'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; export const meta = { desc: { @@ -125,7 +126,8 @@ export default define(meta, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); - generateMuteQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts index d21dceb27c..9d7991b406 100644 --- a/src/server/api/endpoints/users.ts +++ b/src/server/api/endpoints/users.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import define from '../define'; import { Users } from '../../../models'; -import { generateMuteQueryForUsers } from '../common/generate-mute-query'; +import { generateMutedUserQueryForUsers } from '../common/generate-muted-user-query'; export const meta = { tags: ['users'], @@ -87,7 +87,7 @@ export default define(meta, async (ps, me) => { default: query.orderBy('user.id', 'ASC'); break; } - if (me) generateMuteQueryForUsers(query, me); + if (me) generateMutedUserQueryForUsers(query, me); query.take(ps.limit!); query.skip(ps.offset); diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts index f5f08dc891..33e3ecb03f 100644 --- a/src/server/api/endpoints/users/notes.ts +++ b/src/server/api/endpoints/users/notes.ts @@ -6,7 +6,7 @@ import { getUser } from '../../common/getters'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; import { Notes } from '../../../../models'; -import { generateMuteQuery } from '../../common/generate-mute-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { Brackets } from 'typeorm'; export const meta = { @@ -134,7 +134,7 @@ export default define(meta, async (ps, me) => { .leftJoinAndSelect('note.user', 'user'); generateVisibilityQuery(query, me); - if (me) generateMuteQuery(query, me, user); + if (me) generateMutedUserQuery(query, me, user); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts index 73ac615d46..1b59624aa9 100644 --- a/src/server/api/endpoints/users/recommendation.ts +++ b/src/server/api/endpoints/users/recommendation.ts @@ -2,7 +2,7 @@ import * as ms from 'ms'; import $ from 'cafy'; import define from '../../define'; import { Users, Followings } from '../../../../models'; -import { generateMuteQueryForUsers } from '../../common/generate-mute-query'; +import { generateMutedUserQueryForUsers } from '../../common/generate-muted-user-query'; import { generateBlockQueryForUsers } from '../../common/generate-block-query'; export const meta = { @@ -47,7 +47,7 @@ export default define(meta, async (ps, me) => { .andWhere('user.id != :meId', { meId: me.id }) .orderBy('user.followersCount', 'DESC'); - generateMuteQueryForUsers(query, me); + generateMutedUserQueryForUsers(query, me); generateBlockQueryForUsers(query, me); const followingQuery = Followings.createQueryBuilder('following') diff --git a/src/server/api/stream/channel.ts b/src/server/api/stream/channel.ts index 18fa651820..82a95ad3d7 100644 --- a/src/server/api/stream/channel.ts +++ b/src/server/api/stream/channel.ts @@ -15,6 +15,10 @@ export default abstract class Channel { return this.connection.user; } + protected get userProfile() { + return this.connection.userProfile; + } + protected get following() { return this.connection.following; } diff --git a/src/server/api/stream/channels/antenna.ts b/src/server/api/stream/channels/antenna.ts index 714edb502d..b5a792f814 100644 --- a/src/server/api/stream/channels/antenna.ts +++ b/src/server/api/stream/channels/antenna.ts @@ -1,7 +1,7 @@ import autobind from 'autobind-decorator'; import Channel from '../channel'; import { Notes } from '../../../../models'; -import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import { isMutedUserRelated } from '../../../../misc/is-muted-user-related'; export default class extends Channel { public readonly chName = 'antenna'; @@ -25,7 +25,7 @@ export default class extends Channel { const note = await Notes.pack(body.id, this.user, { detail: true }); // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (shouldMuteThisNote(note, this.muting)) return; + if (isMutedUserRelated(note, this.muting)) return; this.send('note', note); } else { diff --git a/src/server/api/stream/channels/global-timeline.ts b/src/server/api/stream/channels/global-timeline.ts index a3ecf8e706..d530907d8d 100644 --- a/src/server/api/stream/channels/global-timeline.ts +++ b/src/server/api/stream/channels/global-timeline.ts @@ -1,9 +1,10 @@ import autobind from 'autobind-decorator'; -import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import { isMutedUserRelated } from '../../../../misc/is-muted-user-related'; import Channel from '../channel'; import { fetchMeta } from '../../../../misc/fetch-meta'; import { Notes } from '../../../../models'; import { PackedNote } from '../../../../models/repositories/note'; +import { checkWordMute } from '../../../../misc/check-word-mute'; export default class extends Channel { public readonly chName = 'globalTimeline'; @@ -45,7 +46,14 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (shouldMuteThisNote(note, this.muting)) return; + if (isMutedUserRelated(note, this.muting)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; this.send('note', note); } diff --git a/src/server/api/stream/channels/hashtag.ts b/src/server/api/stream/channels/hashtag.ts index e55a508328..32d8111f72 100644 --- a/src/server/api/stream/channels/hashtag.ts +++ b/src/server/api/stream/channels/hashtag.ts @@ -1,5 +1,5 @@ import autobind from 'autobind-decorator'; -import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import { isMutedUserRelated } from '../../../../misc/is-muted-user-related'; import Channel from '../channel'; import { Notes } from '../../../../models'; import { PackedNote } from '../../../../models/repositories/note'; @@ -34,7 +34,7 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (shouldMuteThisNote(note, this.muting)) return; + if (isMutedUserRelated(note, this.muting)) return; this.send('note', note); } diff --git a/src/server/api/stream/channels/home-timeline.ts b/src/server/api/stream/channels/home-timeline.ts index 3cf57c294c..caf4ccf5e9 100644 --- a/src/server/api/stream/channels/home-timeline.ts +++ b/src/server/api/stream/channels/home-timeline.ts @@ -1,8 +1,9 @@ import autobind from 'autobind-decorator'; -import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import { isMutedUserRelated } from '../../../../misc/is-muted-user-related'; import Channel from '../channel'; import { Notes } from '../../../../models'; import { PackedNote } from '../../../../models/repositories/note'; +import { checkWordMute } from '../../../../misc/check-word-mute'; export default class extends Channel { public readonly chName = 'homeTimeline'; @@ -50,7 +51,14 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (shouldMuteThisNote(note, this.muting)) return; + if (isMutedUserRelated(note, this.muting)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; this.send('note', note); } diff --git a/src/server/api/stream/channels/hybrid-timeline.ts b/src/server/api/stream/channels/hybrid-timeline.ts index 40686f4b28..1aec98aa72 100644 --- a/src/server/api/stream/channels/hybrid-timeline.ts +++ b/src/server/api/stream/channels/hybrid-timeline.ts @@ -1,10 +1,11 @@ import autobind from 'autobind-decorator'; -import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import { isMutedUserRelated } from '../../../../misc/is-muted-user-related'; import Channel from '../channel'; import { fetchMeta } from '../../../../misc/fetch-meta'; import { Notes } from '../../../../models'; import { PackedNote } from '../../../../models/repositories/note'; import { PackedUser } from '../../../../models/repositories/user'; +import { checkWordMute } from '../../../../misc/check-word-mute'; export default class extends Channel { public readonly chName = 'hybridTimeline'; @@ -59,7 +60,14 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (shouldMuteThisNote(note, this.muting)) return; + if (isMutedUserRelated(note, this.muting)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; this.send('note', note); } diff --git a/src/server/api/stream/channels/local-timeline.ts b/src/server/api/stream/channels/local-timeline.ts index 4b7f74e4f7..6426ccc23f 100644 --- a/src/server/api/stream/channels/local-timeline.ts +++ b/src/server/api/stream/channels/local-timeline.ts @@ -1,10 +1,11 @@ import autobind from 'autobind-decorator'; -import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import { isMutedUserRelated } from '../../../../misc/is-muted-user-related'; import Channel from '../channel'; import { fetchMeta } from '../../../../misc/fetch-meta'; import { Notes } from '../../../../models'; import { PackedNote } from '../../../../models/repositories/note'; import { PackedUser } from '../../../../models/repositories/user'; +import { checkWordMute } from '../../../../misc/check-word-mute'; export default class extends Channel { public readonly chName = 'localTimeline'; @@ -47,7 +48,14 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (shouldMuteThisNote(note, this.muting)) return; + if (isMutedUserRelated(note, this.muting)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; this.send('note', note); } diff --git a/src/server/api/stream/channels/user-list.ts b/src/server/api/stream/channels/user-list.ts index e1b7a88830..4191a0de54 100644 --- a/src/server/api/stream/channels/user-list.ts +++ b/src/server/api/stream/channels/user-list.ts @@ -1,7 +1,7 @@ import autobind from 'autobind-decorator'; import Channel from '../channel'; import { Notes, UserListJoinings, UserLists } from '../../../../models'; -import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import { isMutedUserRelated } from '../../../../misc/is-muted-user-related'; import { User } from '../../../../models/entities/user'; import { PackedNote } from '../../../../models/repositories/note'; @@ -73,7 +73,7 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (shouldMuteThisNote(note, this.muting)) return; + if (isMutedUserRelated(note, this.muting)) return; this.send('note', note); } diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts index b7cefcf5ab..bebf88a7cd 100644 --- a/src/server/api/stream/index.ts +++ b/src/server/api/stream/index.ts @@ -7,15 +7,17 @@ import Channel from './channel'; import channels from './channels'; import { EventEmitter } from 'events'; import { User } from '../../../models/entities/user'; -import { Users, Followings, Mutings } from '../../../models'; +import { Users, Followings, Mutings, UserProfiles } from '../../../models'; import { ApiError } from '../error'; import { AccessToken } from '../../../models/entities/access-token'; +import { UserProfile } from '../../../models/entities/user-profile'; /** * Main stream connection */ export default class Connection { public user?: User; + public userProfile?: UserProfile; public following: User['id'][] = []; public muting: User['id'][] = []; public token?: AccessToken; @@ -25,6 +27,7 @@ export default class Connection { private subscribingNotes: any = {}; private followingClock: NodeJS.Timer; private mutingClock: NodeJS.Timer; + private userProfileClock: NodeJS.Timer; constructor( wsConnection: websocket.connection, @@ -49,6 +52,9 @@ export default class Connection { this.updateMuting(); this.mutingClock = setInterval(this.updateMuting, 5000); + + this.updateUserProfile(); + this.userProfileClock = setInterval(this.updateUserProfile, 5000); } } @@ -262,6 +268,13 @@ export default class Connection { this.muting = mutings.map(x => x.muteeId); } + @autobind + private async updateUserProfile() { + this.userProfile = await UserProfiles.findOne({ + userId: this.user!.id + }); + } + /** * ストリームが切れたとき */ @@ -273,5 +286,6 @@ export default class Connection { if (this.followingClock) clearInterval(this.followingClock); if (this.mutingClock) clearInterval(this.mutingClock); + if (this.userProfileClock) clearInterval(this.userProfileClock); } } diff --git a/src/services/add-note-to-antenna.ts b/src/services/add-note-to-antenna.ts index 88a6613c60..57a0df2752 100644 --- a/src/services/add-note-to-antenna.ts +++ b/src/services/add-note-to-antenna.ts @@ -2,7 +2,7 @@ import { Antenna } from '../models/entities/antenna'; import { Note } from '../models/entities/note'; import { AntennaNotes, Mutings, Notes } from '../models'; import { genId } from '../misc/gen-id'; -import shouldMuteThisNote from '../misc/should-mute-this-note'; +import { isMutedUserRelated } from '../misc/is-muted-user-related'; import { ensure } from '../prelude/ensure'; import { publishAntennaStream, publishMainStream } from './stream'; import { User } from '../models/entities/user'; @@ -39,7 +39,7 @@ export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: U _note.renote = await Notes.findOne(note.renoteId).then(ensure); } - if (shouldMuteThisNote(_note, mutings.map(x => x.muteeId))) { + if (isMutedUserRelated(_note, mutings.map(x => x.muteeId))) { return; } diff --git a/src/services/fetch-instance-metadata.ts b/src/services/fetch-instance-metadata.ts new file mode 100644 index 0000000000..41fef859c9 --- /dev/null +++ b/src/services/fetch-instance-metadata.ts @@ -0,0 +1,135 @@ +import { JSDOM } from 'jsdom'; +import fetch from 'node-fetch'; +import { getJson, getHtml, getAgentByUrl } from '../misc/fetch'; +import { Instance } from '../models/entities/instance'; +import { Instances } from '../models'; +import { getFetchInstanceMetadataLock } from '../misc/app-lock'; +import Logger from './logger'; +import { URL } from 'url'; + +const logger = new Logger('metadata', 'cyan'); + +export async function fetchInstanceMetadata(instance: Instance): Promise<void> { + const unlock = await getFetchInstanceMetadataLock(instance.host); + + const _instance = await Instances.findOne({ host: instance.host }); + const now = Date.now(); + if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { + unlock(); + return; + } + + logger.info(`Fetching metadata of ${instance.host} ...`); + + try { + const [info, icon] = await Promise.all([ + fetchNodeinfo(instance).catch(() => null), + fetchIconUrl(instance).catch(() => null), + ]); + + logger.succ(`Successfuly fetched metadata of ${instance.host}`); + + const updates = { + infoUpdatedAt: new Date(), + } as Record<string, any>; + + if (info) { + updates.softwareName = info.software.name.toLowerCase(); + updates.softwareVersion = info.software.version; + updates.openRegistrations = info.openRegistrations; + updates.name = info.metadata ? (info.metadata.nodeName || info.metadata.name || null) : null; + updates.description = info.metadata ? (info.metadata.nodeDescription || info.metadata.description || null) : null; + updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name || null) : null : null; + updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email || null) : null : null; + } + + if (icon) { + updates.iconUrl = icon; + } + + await Instances.update(instance.id, updates); + + logger.succ(`Successfuly updated metadata of ${instance.host}`); + } catch (e) { + logger.error(`Failed to update metadata of ${instance.host}: ${e}`); + } finally { + unlock(); + } +} + +async function fetchNodeinfo(instance: Instance): Promise<Record<string, any>> { + logger.info(`Fetching nodeinfo of ${instance.host} ...`); + + try { + const wellknown = await getJson('https://' + instance.host + '/.well-known/nodeinfo') + .catch(e => { + if (e.statusCode === 404) { + throw 'No nodeinfo provided'; + } else { + throw e.statusCode || e.message; + } + }); + + if (wellknown.links == null || !Array.isArray(wellknown.links)) { + throw 'No wellknown links'; + } + + const links = wellknown.links as any[]; + + const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); + const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); + const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1'); + const link = lnik2_1 || lnik2_0 || lnik1_0; + + if (link == null) { + throw 'No nodeinfo link provided'; + } + + const info = await getJson(link.href) + .catch(e => { + throw e.statusCode || e.message; + }); + + logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); + + return info; + } catch (e) { + logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${e}`); + + throw e; + } +} + +async function fetchIconUrl(instance: Instance): Promise<string | null> { + logger.info(`Fetching icon URL of ${instance.host} ...`); + + const url = 'https://' + instance.host; + + const html = await getHtml(url); + + const { window } = new JSDOM(html); + const doc = window.document; + + const hrefAppleTouchIconPrecomposed = doc.querySelector('link[rel="apple-touch-icon-precomposed"]')?.getAttribute('href'); + const hrefAppleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href'); + const hrefIcon = doc.querySelector('link[rel="icon"]')?.getAttribute('href'); + + const href = hrefAppleTouchIconPrecomposed || hrefAppleTouchIcon || hrefIcon; + + if (href) { + return (new URL(href, url)).href; + } + + const faviconUrl = url + '/favicon.ico'; + + const favicon = await fetch(faviconUrl, { + timeout: 10000, + agent: getAgentByUrl, + }); + + if (favicon.ok) { + return faviconUrl; + } + + return null; +} diff --git a/src/services/fetch-nodeinfo.ts b/src/services/fetch-nodeinfo.ts deleted file mode 100644 index 0cf51e3377..0000000000 --- a/src/services/fetch-nodeinfo.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { getJson } from '../misc/fetch'; -import { Instance } from '../models/entities/instance'; -import { Instances } from '../models'; -import { getNodeinfoLock } from '../misc/app-lock'; -import Logger from '../services/logger'; - -export const logger = new Logger('nodeinfo', 'cyan'); - -export async function fetchNodeinfo(instance: Instance) { - const unlock = await getNodeinfoLock(instance.host); - - const _instance = await Instances.findOne({ host: instance.host }); - const now = Date.now(); - if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { - unlock(); - return; - } - - logger.info(`Fetching nodeinfo of ${instance.host} ...`); - - try { - const wellknown = await getJson('https://' + instance.host + '/.well-known/nodeinfo') - .catch(e => { - if (e.statusCode === 404) { - throw 'No nodeinfo provided'; - } else { - throw e.statusCode || e.message; - } - }); - - if (wellknown.links == null || !Array.isArray(wellknown.links)) { - throw 'No wellknown links'; - } - - const links = wellknown.links as any[]; - - const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); - const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); - const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1'); - const link = lnik2_1 || lnik2_0 || lnik1_0; - - if (link == null) { - throw 'No nodeinfo link provided'; - } - - const info = await getJson(link.href) - .catch(e => { - throw e.statusCode || e.message; - }); - - await Instances.update(instance.id, { - infoUpdatedAt: new Date(), - softwareName: info.software.name.toLowerCase(), - softwareVersion: info.software.version, - openRegistrations: info.openRegistrations, - name: info.metadata ? (info.metadata.nodeName || info.metadata.name || null) : null, - description: info.metadata ? (info.metadata.nodeDescription || info.metadata.description || null) : null, - maintainerName: info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name || null) : null : null, - maintainerEmail: info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email || null) : null : null, - }); - - logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); - } catch (e) { - logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${e}`); - - await Instances.update(instance.id, { - infoUpdatedAt: new Date(), - }); - } finally { - unlock(); - } -} diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 7b5e6a92ba..44ec5fda6f 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -17,7 +17,7 @@ import extractMentions from '../../misc/extract-mentions'; import extractEmojis from '../../misc/extract-emojis'; import extractHashtags from '../../misc/extract-hashtags'; import { Note, IMentionedRemoteUsers } from '../../models/entities/note'; -import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings } from '../../models'; +import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes } from '../../models'; import { DriveFile } from '../../models/entities/drive-file'; import { App } from '../../models/entities/app'; import { Not, getConnection, In } from 'typeorm'; @@ -29,6 +29,7 @@ import { createNotification } from '../create-notification'; import { isDuplicateKeyValueError } from '../../misc/is-duplicate-key-value-error'; import { ensure } from '../../prelude/ensure'; import { checkHitAntenna } from '../../misc/check-hit-antenna'; +import { checkWordMute } from '../../misc/check-word-mute'; import { addNoteToAntenna } from '../add-note-to-antenna'; import { countSameRenotes } from '../../misc/count-same-renotes'; import { deliverToRelays } from '../relay'; @@ -219,6 +220,24 @@ export default async (user: User, data: Option, silent = false) => new Promise<N // Increment notes count (user) incNotesCountOfUser(user); + // Word mute + UserProfiles.find({ + enableWordMute: true + }).then(us => { + for (const u of us) { + checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { + if (shouldMute) { + MutedNotes.save({ + id: genId(), + userId: u.userId, + noteId: note.id, + reason: 'word', + }); + } + }); + } + }); + // Antenna Antennas.find().then(async antennas => { const followings = await Followings.createQueryBuilder('following') diff --git a/src/types.ts b/src/types.ts index 30a62412a8..d8eb442810 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; + +export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; diff --git a/yarn.lock b/yarn.lock index e3766223f1..e12126fe87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -211,10 +211,10 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@syuilo/aiscript@0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.8.0.tgz#3a895ddd9f5bd5afa1648acb5fd3e6f94f434cbb" - integrity sha512-mrZ3awYf1R81D+OWZctRFiAWUt6xL3A5ovBn2OD8+1hZyX3T7S+awqrhYVLoQPhd/cijz1RT6PE8AEUtuR1J8Q== +"@syuilo/aiscript@0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.10.0.tgz#858c0f84db9dd2c3ffec40c340966c6bcc096e1d" + integrity sha512-Nrhzsb0JfplazCl2biLqlC9sUcrwAEu7dF5MaFZc2lbR4gAMX1i1ijFuH4V2NRG0P81l8cikGCpMnjhxfS6qGw== dependencies: autobind-decorator "2.4.0" chalk "4.0.0" @@ -3362,6 +3362,11 @@ entities@^2.0.0, entities@~2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== +env-paths@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43" + integrity sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA== + errno@^0.1.3: version "0.1.7" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" @@ -4258,6 +4263,11 @@ graceful-fs@4.X, graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, g resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== +graceful-fs@^4.2.3: + version "4.2.4" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== + growl@1.10.5: version "1.10.5" resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" @@ -4797,6 +4807,11 @@ insert-text-at-cursor@0.3.0: resolved "https://registry.yarnpkg.com/insert-text-at-cursor/-/insert-text-at-cursor-0.3.0.tgz#1819607680ec1570618347c4cd475e791faa25da" integrity sha512-/nPtyeX9xPUvxZf+r0518B7uqNKlP+LqNJqSiXFEaa2T71rWIwTVXGH7hB9xO/EVdwa5/pWlFCPwShOW81XIxQ== +install-artifact-from-github@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/install-artifact-from-github/-/install-artifact-from-github-1.0.2.tgz#e1e478dd29880b9112ecd684a84029603e234a9d" + integrity sha512-yuMFBSVIP3vD0SDBGUqeIpgOAIlFx8eQFknQObpkYEM5gsl9hy6R9Ms3aV+Vw9MMyYsoPMeex0XDnfgY7uzc+Q== + interpret@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" @@ -6338,7 +6353,7 @@ mz@^2.4.0, mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.14.0: +nan@^2.14.0, nan@^2.14.1: version "2.14.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== @@ -6434,6 +6449,22 @@ node-forge@^0.9.1: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5" integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ== +node-gyp@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-7.0.0.tgz#2e88425ce84e9b1a4433958ed55d74c70fffb6be" + integrity sha512-ZW34qA3CJSPKDz2SJBHKRvyNQN0yWO5EGKKksJc+jElu9VA468gwJTyTArC1iOXU7rN3Wtfg/CMt/dBAOFIjvg== + dependencies: + env-paths "^2.2.0" + glob "^7.1.4" + graceful-fs "^4.2.3" + nopt "^4.0.3" + npmlog "^4.1.2" + request "^2.88.2" + rimraf "^2.6.3" + semver "^7.3.2" + tar "^6.0.1" + which "^2.0.2" + node-object-hash@^1.2.0: version "1.4.2" resolved "https://registry.yarnpkg.com/node-object-hash/-/node-object-hash-1.4.2.tgz#385833d85b229902b75826224f6077be969a9e94" @@ -7931,6 +7962,15 @@ rdf-canonize@^1.0.2: node-forge "^0.9.1" semver "^6.3.0" +re2@1.15.4: + version "1.15.4" + resolved "https://registry.yarnpkg.com/re2/-/re2-1.15.4.tgz#2ffc3e4894fb60430393459978197648be01a0a9" + integrity sha512-7w3K+Daq/JjbX/dz5voMt7B9wlprVBQnMiypyCojAZ99kcAL+3LiJ5uBoX/u47l8eFTVq3Wj+V0pmvU+CT8tOg== + dependencies: + install-artifact-from-github "^1.0.2" + nan "^2.14.1" + node-gyp "^7.0.0" + read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -8339,7 +8379,7 @@ rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rimraf@^2.6.2: +rimraf@^2.6.2, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -9254,7 +9294,7 @@ tar-stream@^2.0.0: inherits "^2.0.3" readable-stream "^3.1.1" -tar@^6.0.2: +tar@^6.0.1, tar@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39" integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg== @@ -10304,7 +10344,7 @@ which-pm-runs@^1.0.0: resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb" integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= -which@2.0.2, which@^2.0.1: +which@2.0.2, which@^2.0.1, which@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==