Compare commits

...

57 Commits

Author SHA1 Message Date
syuilo
5ec1d96048 Update CHANGELOG.md 2023-11-02 15:56:09 +09:00
syuilo
441c0ca465 wip 2023-11-02 15:50:20 +09:00
syuilo
29b06994a2 wip 2023-11-02 15:31:15 +09:00
syuilo
314fb4bfd9 wip 2023-11-02 15:04:47 +09:00
syuilo
ce5ff70cb3 wip 2023-11-02 13:46:53 +09:00
syuilo
6a73f7c108 i/updateのレートリミットを緩和 2023-11-01 20:29:58 +09:00
syuilo
c54baf873b 2023.11.0-beta.7 2023-11-01 17:40:07 +09:00
syuilo
e88dbad3cf tweak test 2023-11-01 15:56:57 +09:00
syuilo
5772de2a62 fix vitest 2023-11-01 15:40:10 +09:00
syuilo
821633f878 🎨 2023-11-01 15:36:15 +09:00
syuilo
9b073e5fe6 fix 2023-11-01 15:32:35 +09:00
syuilo
77db652bff update deps 2023-11-01 14:06:54 +09:00
syuilo
e632a84431 tweak default configuration 2023-11-01 14:00:31 +09:00
syuilo
7ed2a5fc1b perf(backend): remove needless query
#12206
2023-11-01 13:55:19 +09:00
かっこかり
5fb6847419 Feat: 未読通知数を表示できるように (#11982)
* 未読通知数を表示できるように

* Update Changelog

* オプトインにする

* Fix lint

* (add) テスト通知のプッシュ通知を追加

* add test

* フロントエンドの表示上限を99に変更

* Make it default on

* 共通スタイルをくくりだす

* Update Changelog

* tweak

* Update UserEntityService.ts

* rename

* Update navbar-for-mobile.vue

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-11-01 13:34:05 +09:00
syuilo
e85b8217c0 enhance(frontend): 「内容を隠す」でリアクションも隠れるように
Resolve #12199
2023-11-01 11:23:23 +09:00
syuilo
d6fe897923 perf(frontend): ノート表示時のMFMパース処理を減らす 2023-11-01 10:23:20 +09:00
syuilo
bf01c1ee64 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2023-10-31 20:14:39 +09:00
syuilo
7d3721dded Update .eslintrc.js 2023-10-31 20:14:36 +09:00
syuilo
735f22c1c5 Update CHANGELOG.md 2023-10-31 17:29:21 +09:00
anatawa12
cf026e4c72 feat: add tools to navbar (#12204)
* feat: add tools to navbar

* docs(changelog): ナビゲーションバーにツールを追加しました
2023-10-31 17:28:13 +09:00
果物リン
e2f34e3db6 fix: headerActionにPC以外で空のボタンが出てしまうバグの修正 (#12202)
* headerActionにPC以外で空のボタンが出てしまうバグの修正

* fix eslint
2023-10-31 17:26:59 +09:00
_
7c692283ad fix(backend): 自身の宛先なしダイレクト投稿がストリーミングで流れてこない問題を修正 (#12203)
* fix: dm stream

* add CHANGELOG
2023-10-31 15:27:20 +09:00
syuilo
e6e5bf1da4 🎨 2023-10-31 13:46:58 +09:00
syuilo
a35fe29ef4 fix(backend): アクセストークンを削除すると、通知が取得できなくなる場合がある問題を修正 2023-10-31 11:45:03 +09:00
syuilo
56c5da97e6 Update CHANGELOG.md 2023-10-30 20:07:51 +09:00
syuilo
af779ebff9 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2023-10-30 19:38:30 +09:00
syuilo
4eab3c07fd lint fixes 2023-10-30 19:38:27 +09:00
Shun Sakai
359f3d5ef5 chore: Convert issue templates to YAML (#12194) 2023-10-30 19:35:10 +09:00
syuilo
d45b2dd3a7 lint fix 2023-10-30 17:36:32 +09:00
syuilo
b4dd61a016 Update .eslintrc.js 2023-10-30 17:31:49 +09:00
GrapeApple0
4f180ad45c feat: アイコンデコレーションの管理をロールで設定できるように (#12173)
* アイコンデコレーションの管理をロールで設定できるように

* インポートミス

* Update packages/frontend/src/ui/_common_/common.ts

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

* Update packages/frontend/src/ui/_common_/common.ts

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

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-10-30 15:33:15 +09:00
syuilo
52dbab56a4 tweak MkPullToRefresh 2023-10-30 15:16:59 +09:00
anatawa12
7015cc937b fix(backend): We can renote pure renote (#12171)
* chore: make pure renote detection an function

* fix: we can renote pure renote

* docs(changelog): リノートをリノートできるのを修正

* fix: remaining debug log

* chore: move isPureRenote to misc

* chore: make isPureRenote type guard

* chore: use isPureRenote in other places

* fix CHANGELOG

* style: fix lint

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-10-30 13:48:22 +09:00
syuilo
50b16e36c7 enhance(frontend): URL入力フォームのtypeをurlに
Resolve #12137
2023-10-30 13:41:40 +09:00
syuilo
e512f8c56d fix(frontend): 標準テーマと同じIDを使用してインストールできてしまう問題を修正
Fix #12188
2023-10-30 13:38:03 +09:00
syuilo
183e5cef8b Update CHANGELOG.md 2023-10-30 13:32:52 +09:00
dependabot[bot]
38c163d67c chore(deps): bump actions/setup-node from 3.8.1 to 4.0.0 (#12128)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3.8.1 to 4.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3.8.1...v4.0.0)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-30 12:43:29 +09:00
syuilo
20f70f1c39 2023.11.0-beta.6 2023-10-30 09:14:25 +09:00
_
c239058624 feat(frontend): スワイプやボタンでタイムラインを再読込する機能 (#12113)
* pc reloading

* add: disable TL websocket option

* fix: stream disconnect when reload

* add: pull to refresh

* fix: pull to refresh

* add changelog

* fact: change to disableStreamingTimeline

* lint

* remove: en-US text

* refactor

* refactor

* add license identifier

* tweak

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* change name timeoutHeartBeat

* tweak

* 🎨

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-10-30 09:12:20 +09:00
かっこかり
117db08880 fix(backend): プロフィールの自己紹介欄のMFMを連合するように(実装漏れ) (#12185)
* (refactor) eliminate nested ternary operation

* fix lint

* Jissou more
2023-10-30 08:20:32 +09:00
おさむのひと
2de4d3329d Fix: フォローしたユーザが、自分のフォローしていないチャンネルにノートを投稿した時、そのノートがHTLで見えてしまう (#12186)
* #12181 の問題に対処
「ユーザー・チャンネルともにフォローあり」のときの絞り込みに不備があり、フォローしているユーザの投稿であればチャンネルのフォロー関係なく通過するようになってしまっていた

* fix CHANGELOG.md

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-10-30 08:19:27 +09:00
syuilo
8f01757a7f Update CHANGELOG.md 2023-10-30 08:18:43 +09:00
yupix
d9cfea8b10 fix: 個人カードのemojiがバッテリーになっている #12189 (#12190) 2023-10-30 08:17:42 +09:00
syuilo
cb1449be09 2023.11.0-beta.5 2023-10-29 19:39:08 +09:00
syuilo
9ad48dae04 Update CHANGELOG.md 2023-10-29 19:34:44 +09:00
かっこかり
59cc101752 fix(backend): プロフィールの自己紹介欄のMFMを連合するように (#12184)
* (fix) federate user description mfm

* fix

* Update Changelog
2023-10-29 19:33:35 +09:00
syuilo
aefc941df3 typo 2023-10-29 16:28:32 +09:00
まっちゃてぃー
2da55f70a7 Feat: リモートユーザーの更新をできるように (#12172)
* Feat: リモートユーザーの更新をできるように

Signed-off-by: mattyatea <mattyacocacora0@gmail.com>

* Update packages/frontend/src/scripts/get-user-menu.ts

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

* Update packages/frontend/src/scripts/get-user-menu.ts

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

---------

Signed-off-by: mattyatea <mattyacocacora0@gmail.com>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-10-29 16:14:48 +09:00
syuilo
0fc36d11d7 fix(backend): STLにGTLの投稿が混ざる問題を修正
Fix #12169
2023-10-29 16:13:30 +09:00
syuilo
7436e0da18 lint fixes 2023-10-29 16:09:20 +09:00
おさむのひと
a161a9c1e7 Fix: notes/timelineにフォローしているチャンネルのノートを含める (#12179)
* notes/timelineにフォローしているチャンネルのノートを含める

* fix CHANGELOG.md

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-10-29 14:16:36 +09:00
かっこかり
1a8243f1ca MkCodeのパースエンジンをShikiに変更 (#12102)
* (swap) prism -> shiki

* fix styles

* (bump) aiscript-vscode to v0.0.5

* refactor

* replace prism-editor (beta)

* Update scratchpad.vue

* (enhance) MkCodeEditor自動インデント改行

* (fix) lint

* (add) scratchpad: MkStickyContainer

* Update CHANGELOG.md

* clean up

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-10-29 14:12:40 +09:00
syuilo
feedad7d8b enhance(frontend): tweak about-misskey page 2023-10-29 13:49:26 +09:00
syuilo
b627978d00 Update CHANGELOG.md 2023-10-29 13:12:04 +09:00
syuilo
2a61a0c026 Update .eslintrc.js 2023-10-29 13:07:49 +09:00
yupix
5887c5da6c feat: チャンネルの作成・更新時にapiWithDialogを使うように (#12142)
* feat: チャンネル作成、更新時にapiWithDialogを使うように

* chore: 不要なsuccessの呼び出しを削除

* chore: 誤って削除した必要なコードを元通りに
2023-10-29 11:10:01 +09:00
130 changed files with 2825 additions and 1186 deletions

View File

@@ -1,60 +0,0 @@
---
name: 🐛 Bug Report
about: Create a report to help us improve
title: ''
labels: ⚠bug?
assignees: ''
---
<!--
Thanks for reporting!
First, in order to avoid duplicate Issues, please search to see if the problem you found has already been reported.
Also, If you are NOT owner/admin of server, PLEASE DONT REPORT SERVER SPECIFIC ISSUES TO HERE! (e.g. feature XXX is not working in misskey.example) Please try with another misskey servers, and if your issue is only reproducible with specific server, contact your server's owner/admin first.
-->
## 💡 Summary
<!-- Tell us what the bug is -->
## 🥰 Expected Behavior
<!--- Tell us what should happen -->
## 🤬 Actual Behavior
<!--
Tell us what happens instead of the expected behavior.
Please include errors from the developer console and/or server log files if you have access to them.
-->
## 📝 Steps to Reproduce
1.
2.
3.
## 📌 Environment
<!-- Tell us where on the platform it happens -->
<!-- DO NOT WRITE "latest". Please provide the specific version. -->
### 💻 Frontend
* Model and OS of the device(s):
<!-- Example: MacBook Pro (14inch, 2021), macOS Ventura 13.4 -->
* Browser:
<!-- Example: Chrome 113.0.5672.126 -->
* Server URL:
<!-- Example: misskey.io -->
* Misskey:
13.x.x
### 🛰 Backend (for server admin)
<!-- If you are using a managed service, put that after the version. -->
* Installation Method or Hosting Service: <!-- Example: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment -->
* Misskey: 13.x.x
* Node: 20.x.x
* PostgreSQL: 15.x.x
* Redis: 7.x.x
* OS and Architecture: <!-- Example: Ubuntu 22.04.2 LTS aarch64 -->

View File

@@ -0,0 +1,91 @@
name: 🐛 Bug Report
description: Create a report to help us improve
labels: ["⚠bug?"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting!
First, in order to avoid duplicate Issues, please search to see if the problem you found has already been reported.
Also, If you are NOT owner/admin of server, PLEASE DONT REPORT SERVER SPECIFIC ISSUES TO HERE! (e.g. feature XXX is not working in misskey.example) Please try with another misskey servers, and if your issue is only reproducible with specific server, contact your server's owner/admin first.
- type: textarea
attributes:
label: 💡 Summary
description: Tell us what the bug is
validations:
required: true
- type: textarea
attributes:
label: 🥰 Expected Behavior
description: Tell us what should happen
validations:
required: true
- type: textarea
attributes:
label: 🤬 Actual Behavior
description: |
Tell us what happens instead of the expected behavior.
Please include errors from the developer console and/or server log files if you have access to them.
validations:
required: true
- type: textarea
attributes:
label: 📝 Steps to Reproduce
placeholder: |
1.
2.
3.
validations:
required: false
- type: textarea
attributes:
label: 💻 Frontend Environment
description: |
Tell us where on the platform it happens
DO NOT WRITE "latest". Please provide the specific version.
Examples:
* Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4
* Browser: Chrome 113.0.5672.126
* Server URL: misskey.io
* Misskey: 13.x.x
value: |
* Model and OS of the device(s):
* Browser:
* Server URL:
* Misskey:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: 🛰 Backend Environment (for server admin)
description: |
Tell us where on the platform it happens
DO NOT WRITE "latest". Please provide the specific version.
If you are using a managed service, put that after the version.
Examples:
* Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment
* Misskey: 13.x.x
* Node: 20.x.x
* PostgreSQL: 15.x.x
* Redis: 7.x.x
* OS and Architecture: Ubuntu 22.04.2 LTS aarch64
value: |
* Installation Method or Hosting Service:
* Misskey:
* Node:
* PostgreSQL:
* Redis:
* OS and Architecture:
render: markdown
validations:
required: false

View File

@@ -1,12 +0,0 @@
---
name: ✨ Feature Request
about: Suggest an idea for this project
title: ''
labels: ✨Feature
assignees: ''
---
## Summary
<!-- Tell us what the suggestion is -->

View File

@@ -0,0 +1,11 @@
name: ✨ Feature Request
description: Suggest an idea for this project
labels: ["✨Feature"]
body:
- type: textarea
attributes:
label: Summary
description: Tell us what the suggestion is
validations:
required: true

View File

@@ -14,7 +14,7 @@ jobs:
- run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v4.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@@ -44,7 +44,7 @@ jobs:
version: 8
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v4.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
@@ -126,7 +126,7 @@ jobs:
version: 8
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v4.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@@ -19,7 +19,7 @@ jobs:
with:
version: 8
run_install: false
- uses: actions/setup-node@v3.8.1
- uses: actions/setup-node@v4.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -46,7 +46,7 @@ jobs:
with:
version: 7
run_install: false
- uses: actions/setup-node@v3.8.1
- uses: actions/setup-node@v4.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -72,7 +72,7 @@ jobs:
with:
version: 7
run_install: false
- uses: actions/setup-node@v3.8.1
- uses: actions/setup-node@v4.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@@ -38,7 +38,7 @@ jobs:
version: 8
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v4.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@@ -25,7 +25,7 @@ jobs:
version: 8
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v4.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
@@ -83,7 +83,7 @@ jobs:
version: 7
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v4.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@@ -26,7 +26,7 @@ jobs:
- run: corepack enable
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v4.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@@ -28,7 +28,7 @@ jobs:
version: 8
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v4.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

View File

@@ -16,30 +16,57 @@
### General
- Feat: アイコンデコレーション機能
- サーバーで用意された画像をアイコンに重ねることができます
- 画像のテンプレートはこちらです: https://misskey-hub.net/avatar-decoration-template.png
- 最大でも黄色いエリア内にデコレーションを収めることを推奨します。
- 画像は512x512pxを推奨します。
- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように
- Enhance: 未読の通知数を表示できるように
- Enhance: ローカリゼーションの更新
- Enhance: 依存関係の更新
### Client
- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました
- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください
https://misskey-hub.net/docs/advanced/publish-on-your-website.html
- Enhance: スワイプしてタイムラインを再読込できるように
- PCの場合は右上のボタンからでも再読込できます
- Enhance: タイムラインの自動更新を無効にできるように
- Enhance: 通知をグルーピングして表示するオプション(オプトアウト)
- Enhance: コードのシンタックスハイライトエンジンをShikiに変更
- AiScriptのシンタックスハイライトに対応
- MFMでAiScriptをハイライトする場合、コードブロックの開始部分を ` ```is ` もしくは ` ```aiscript ` としてください
- Enhance: データセーバー有効時はアニメーション付きのアバター画像が停止するように
- Enhance: プラグインを削除した際には、使用されていたアクセストークンも同時に削除されるようになりました
- Enhance: プラグインで`Plugin:register_note_view_interruptor`を用いてnoteの代わりにnullを返却することでートを非表示にできるようになりました
- Enhance: AiScript関数`Mk:nyaize()`が追加されました
- Enhance: 情報→ツール はナビゲーションバーにツールとして独立した項目になりました
- Enhance: その他細かなブラッシュアップ
- Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正
- Fix: ユーザーページの ノート > ファイル付き タブにリプライが表示されてしまう
- Fix: 「検索」MFMにおいて一部の検索キーワードが正しく認識されない問題を修正
- Fix: 一部の言語でMisskey Webがクラッシュする問題を修正
- Fix: チャンネルの作成・更新時に失敗した場合何も表示されない問題を修正 #11983
- Fix: 個人カードのemojiがバッテリーになっている問題を修正
- Fix: 標準テーマと同じIDを使用してインストールできてしまう問題を修正
### Server
- Enhance: RedisへのTLのキャッシュをオフにできるように
- Enhance: RedisへのTLのキャッシュ(FTT)をオフにできるように
- Enhance: フォローしているチャンネルをフォロー解除した時(またはその逆)、タイムラインに反映される間隔を改善
- Enhance: プロフィールの自己紹介欄のMFMが連合するようになりました
- 相手がMisskey v2023.11.0以降である必要があります
- Enhance: チャンネル取得時のパフォーマンスを向上
- Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正
- Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正
- Fix: 自分のフォローしているユーザーの自分のフォローしていないユーザーの visibility: followers な投稿への返信がストリーミングで流れてくる問題を修正
- Fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STLが空になることがある問題を修正
- Fix: STLでフォローしていないチャンネルが取得される問題を修正
- Fix: `hashtags/trend`にてRedisからトレンドの情報が取得できない際にInternal Server Errorになる問題を修正
- Fix: HTLをリロードまたは遡行したとき、フォローしているチャンネルのートが含まれない問題を修正 #11765 #12181
- Fix: リノートをリノートできるのを修正
- Fix: アクセストークンを削除すると、通知が取得できなくなる場合がある問題を修正
- Fix: 自身の宛先なしダイレクト投稿がストリーミングで流れてこない問題を修正
- Fix: サーバーサイドからのテスト通知を正しく行えるように修正
## 2023.10.2

11
locales/index.d.ts vendored
View File

@@ -982,6 +982,7 @@ export interface Locale {
"unassign": string;
"color": string;
"manageCustomEmojis": string;
"manageAvatarDecorations": string;
"youCannotCreateAnymore": string;
"cannotPerformTemporary": string;
"cannotPerformTemporaryDescription": string;
@@ -1152,6 +1153,11 @@ export interface Locale {
"angle": string;
"flip": string;
"showAvatarDecorations": string;
"releaseToRefresh": string;
"refreshing": string;
"pullDownToRefresh": string;
"disableStreamingTimeline": string;
"useGroupedNotifications": string;
"_announcement": {
"forExistingUsers": string;
"forExistingUsersDescription": string;
@@ -1571,6 +1577,7 @@ export interface Locale {
"inviteLimitCycle": string;
"inviteExpirationTime": string;
"canManageCustomEmojis": string;
"canManageAvatarDecorations": string;
"driveCapacity": string;
"alwaysMarkNsfw": string;
"pinMax": string;
@@ -1707,6 +1714,7 @@ export interface Locale {
"donate": string;
"morePatrons": string;
"patrons": string;
"projectMembers": string;
};
"_displayOfSensitiveMedia": {
"respect": string;
@@ -2193,6 +2201,9 @@ export interface Locale {
"checkNotificationBehavior": string;
"sendTestNotification": string;
"notificationWillBeDisplayedLikeThis": string;
"reactedBySomeUsers": string;
"renotedBySomeUsers": string;
"followedBySomeUsers": string;
"_types": {
"all": string;
"note": string;

View File

@@ -979,6 +979,7 @@ assign: "アサイン"
unassign: "アサインを解除"
color: "色"
manageCustomEmojis: "カスタム絵文字の管理"
manageAvatarDecorations: "アバターデコレーションの管理"
youCannotCreateAnymore: "これ以上作成することはできません。"
cannotPerformTemporary: "一時的に利用できません"
cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。"
@@ -1149,6 +1150,11 @@ detach: "外す"
angle: "角度"
flip: "反転"
showAvatarDecorations: "アイコンのデコレーションを表示"
releaseToRefresh: "離してリロード"
refreshing: "リロード中"
pullDownToRefresh: "引っ張ってリロード"
disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする"
useGroupedNotifications: "通知をグルーピングして表示する"
_announcement:
forExistingUsers: "既存ユーザーのみ"
@@ -1492,6 +1498,7 @@ _role:
inviteLimitCycle: "招待コードの発行間隔"
inviteExpirationTime: "招待コードの有効期限"
canManageCustomEmojis: "カスタム絵文字の管理"
canManageAvatarDecorations: "アバターデコレーションの管理"
driveCapacity: "ドライブ容量"
alwaysMarkNsfw: "ファイルにNSFWを常に付与"
pinMax: "ノートのピン留めの最大数"
@@ -1617,13 +1624,14 @@ _registry:
_aboutMisskey:
about: "Misskeyはsyuiloによって2014年から開発されている、オープンソースのソフトウェアです。"
contributors: "主なコントリビューター"
contributors: "コントリビューター"
allContributors: "全てのコントリビューター"
source: "ソースコード"
translation: "Misskeyを翻訳"
donate: "Misskeyに寄付"
morePatrons: "他にも多くの方が支援してくれています。ありがとうございます🥰"
patrons: "支援者"
projectMembers: "プロジェクトメンバー"
_displayOfSensitiveMedia:
respect: "センシティブ設定されたメディアを隠す"
@@ -2107,6 +2115,9 @@ _notification:
checkNotificationBehavior: "通知の表示を確かめる"
sendTestNotification: "テスト通知を送信する"
notificationWillBeDisplayedLikeThis: "通知はこのように表示されます"
reactedBySomeUsers: "{n}人がリアクションしました"
renotedBySomeUsers: "{n}人がリノートしました"
followedBySomeUsers: "{n}人にフォローされました"
_types:
all: "すべて"

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2023.11.0-beta.4",
"version": "2023.11.0-beta.7",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -48,14 +48,14 @@
"cssnano": "6.0.1",
"js-yaml": "4.1.0",
"postcss": "8.4.31",
"terser": "5.22.0",
"terser": "5.24.0",
"typescript": "5.2.2"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "6.9.0",
"@typescript-eslint/parser": "6.9.0",
"@typescript-eslint/eslint-plugin": "6.9.1",
"@typescript-eslint/parser": "6.9.1",
"cross-env": "7.0.3",
"cypress": "13.3.3",
"cypress": "13.4.0",
"eslint": "8.52.0",
"start-server-and-test": "2.0.1"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -66,17 +66,17 @@
"@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.2.0",
"@fastify/cookie": "9.1.0",
"@fastify/cors": "8.4.0",
"@fastify/cors": "8.4.1",
"@fastify/express": "2.3.0",
"@fastify/http-proxy": "9.2.1",
"@fastify/multipart": "8.0.0",
"@fastify/static": "6.11.2",
"@fastify/static": "6.12.0",
"@fastify/view": "8.2.0",
"@nestjs/common": "10.2.7",
"@nestjs/core": "10.2.7",
"@nestjs/testing": "10.2.7",
"@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "8.3.4",
"@simplewebauthn/server": "8.3.5",
"@sinonjs/fake-timers": "11.2.2",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.95",
@@ -87,7 +87,7 @@
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"body-parser": "1.20.2",
"bullmq": "4.12.6",
"bullmq": "4.12.7",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.1",
"chalk": "5.3.0",
@@ -138,7 +138,7 @@
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"pug": "3.0.2",
"punycode": "2.3.0",
"punycode": "2.3.1",
"pureimage": "0.3.17",
"qrcode": "1.5.3",
"random-seed": "0.3.0",
@@ -175,7 +175,7 @@
"@simplewebauthn/typescript-types": "8.3.4",
"@swc/jest": "0.2.29",
"@types/accepts": "1.3.6",
"@types/archiver": "5.3.4",
"@types/archiver": "6.0.0",
"@types/bcryptjs": "2.4.5",
"@types/body-parser": "1.19.4",
"@types/cbor": "6.0.0",
@@ -183,14 +183,14 @@
"@types/content-disposition": "0.5.7",
"@types/fluent-ffmpeg": "2.1.23",
"@types/http-link-header": "1.0.4",
"@types/jest": "29.5.6",
"@types/jest": "29.5.7",
"@types/js-yaml": "4.0.8",
"@types/jsdom": "21.1.4",
"@types/jsonld": "1.5.11",
"@types/jsrsasign": "10.5.11",
"@types/mime-types": "2.1.3",
"@types/ms": "0.7.33",
"@types/node": "20.8.9",
"@types/node": "20.8.10",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.13",
"@types/oauth": "0.9.3",
@@ -213,8 +213,8 @@
"@types/vary": "1.1.2",
"@types/web-push": "3.6.2",
"@types/ws": "8.5.8",
"@typescript-eslint/eslint-plugin": "6.9.0",
"@typescript-eslint/parser": "6.9.0",
"@typescript-eslint/eslint-plugin": "6.9.1",
"@typescript-eslint/parser": "6.9.1",
"aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3",
"eslint": "8.52.0",

View File

@@ -258,7 +258,7 @@ export function loadConfig(): Config {
clientEntry: clientManifest['src/_boot_.ts'],
clientManifestExists: clientManifestExists,
perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000,
perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 300,
perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500,
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
pidFile: config.pidFile,
};

View File

@@ -100,17 +100,14 @@ class NotificationManager {
}
@bindThis
public async deliver() {
public async notify() {
for (const x of this.queue) {
// ミュート情報を取得
const mentioneeMutes = await this.mutingsRepository.findBy({
muterId: x.target,
});
const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId);
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
if (x.reason === 'renote') {
this.notificationService.createNotification(x.target, 'renote', {
noteId: this.note.id,
targetNoteId: this.note.renoteId!,
}, this.notifier.id);
} else {
this.notificationService.createNotification(x.target, x.reason, {
noteId: this.note.id,
}, this.notifier.id);
@@ -642,7 +639,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
nm.deliver();
nm.notify();
//#region AP deliver
if (this.userEntityService.isLocalUser(user)) {

View File

@@ -24,6 +24,7 @@ import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
@Injectable()
export class NoteDeleteService {
@@ -77,8 +78,8 @@ export class NoteDeleteService {
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
let renote: MiNote | null = null;
// if deletd note is renote
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
// if deleted note is renote
if (isPureRenote(note)) {
renote = await this.notesRepository.findOneBy({
id: note.renoteId,
});

View File

@@ -19,6 +19,7 @@ import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
import { UserListService } from '@/core/UserListService.js';
import type { FilterUnionByProperty } from '@/types.js';
@Injectable()
export class NotificationService implements OnApplicationShutdown {
@@ -73,10 +74,10 @@ export class NotificationService implements OnApplicationShutdown {
}
@bindThis
public async createNotification(
public async createNotification<T extends MiNotification['type']>(
notifieeId: MiUser['id'],
type: MiNotification['type'],
data: Omit<Partial<MiNotification>, 'notifierId'>,
type: T,
data: Omit<FilterUnionByProperty<MiNotification, 'type', T>, 'type' | 'id' | 'createdAt' | 'notifierId'>,
notifierId?: MiUser['id'] | null,
): Promise<MiNotification | null> {
const profile = await this.cacheService.userProfileCache.fetch(notifieeId);
@@ -128,9 +129,11 @@ export class NotificationService implements OnApplicationShutdown {
id: this.idService.gen(),
createdAt: new Date(),
type: type,
notifierId: notifierId,
...(notifierId ? {
notifierId,
} : {}),
...data,
} as MiNotification;
} as any as FilterUnionByProperty<MiNotification, 'type', T>;
const redisIdPromise = this.redisClient.xadd(
`notificationTimeline:${notifieeId}`,
@@ -144,7 +147,9 @@ export class NotificationService implements OnApplicationShutdown {
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
// テスト通知の場合は即時発行
const interval = notification.type === 'test' ? 0 : 2000;
setTimeout(interval, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return;

View File

@@ -32,6 +32,7 @@ export type RolePolicies = {
inviteLimitCycle: number;
inviteExpirationTime: number;
canManageCustomEmojis: boolean;
canManageAvatarDecorations: boolean;
canSearchNotes: boolean;
canUseTranslator: boolean;
canHideAds: boolean;
@@ -57,6 +58,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
inviteLimitCycle: 60 * 24 * 7,
inviteExpirationTime: 0,
canManageCustomEmojis: false,
canManageAvatarDecorations: false,
canSearchNotes: false,
canUseTranslator: true,
canHideAds: false,
@@ -306,6 +308,7 @@ export class RoleService implements OnApplicationShutdown {
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),

View File

@@ -509,7 +509,6 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成
this.notificationService.createNotification(followee.id, 'receiveFollowRequest', {
followRequestId: followRequest.id,
}, follower.id);
}

View File

@@ -495,6 +495,7 @@ export class ApRendererService {
preferredUsername: user.username,
name: user.name,
summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null,
_misskey_summary: profile.description,
icon: avatar ? this.renderImage(avatar) : null,
image: banner ? this.renderImage(banner) : null,
tag,
@@ -644,6 +645,7 @@ export class ApRendererService {
'_misskey_quote': 'misskey:_misskey_quote',
'_misskey_reaction': 'misskey:_misskey_reaction',
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_summary': 'misskey:_misskey_summary',
'isCat': 'misskey:isCat',
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',

View File

@@ -319,9 +319,17 @@ export class ApPersonService implements OnModuleInit {
emojis,
})) as MiRemoteUser;
let _description: string | null = null;
if (person._misskey_summary) {
_description = truncate(person._misskey_summary, summaryLength);
} else if (person.summary) {
_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag);
}
await transactionalEntityManager.save(new MiUserProfile({
userId: user.id,
description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
description: _description,
url,
fields,
birthday: bday?.[0] ?? null,
@@ -487,10 +495,18 @@ export class ApPersonService implements OnModuleInit {
});
}
let _description: string | null = null;
if (person._misskey_summary) {
_description = truncate(person._misskey_summary, summaryLength);
} else if (person.summary) {
_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag);
}
await this.userProfilesRepository.update({ userId: exist.id }, {
url,
fields,
description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
description: _description,
birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null,
});

View File

@@ -12,6 +12,7 @@ export interface IObject {
id?: string;
name?: string | null;
summary?: string;
_misskey_summary?: string;
published?: string;
cc?: ApObject;
to?: ApObject;

View File

@@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository, NotesRepository } from '@/models/_.js';
import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
@@ -31,9 +31,6 @@ export class ChannelEntityService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.noteUnreadsRepository)
private noteUnreadsRepository: NoteUnreadsRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@@ -54,13 +51,6 @@ export class ChannelEntityService {
const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null;
const hasUnreadNote = meId ? await this.noteUnreadsRepository.exist({
where: {
noteChannelId: channel.id,
userId: meId,
},
}) : undefined;
const isFollowing = meId ? await this.channelFollowingsRepository.exist({
where: {
followerId: meId,
@@ -99,7 +89,7 @@ export class ChannelEntityService {
...(me ? {
isFollowing,
isFavorited,
hasUnreadNote,
hasUnreadNote: false, // 後方互換性のため
} : {}),
...(detailed ? {

View File

@@ -7,20 +7,21 @@ import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { AccessTokensRepository, FollowRequestsRepository, NotesRepository, MiUser, UsersRepository } from '@/models/_.js';
import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { MiNotification } from '@/models/Notification.js';
import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js';
import type { MiNote } from '@/models/Note.js';
import type { Packed } from '@/misc/json-schema.js';
import { bindThis } from '@/decorators.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { notificationTypes } from '@/types.js';
import { FilterUnionByProperty, notificationTypes } from '@/types.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]);
const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded']);
@Injectable()
export class NotificationEntityService implements OnModuleInit {
@@ -40,9 +41,6 @@ export class NotificationEntityService implements OnModuleInit {
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.accessTokensRepository)
private accessTokensRepository: AccessTokensRepository,
//private userEntityService: UserEntityService,
//private noteEntityService: NoteEntityService,
//private customEmojiService: CustomEmojiService,
@@ -69,18 +67,17 @@ export class NotificationEntityService implements OnModuleInit {
},
): Promise<Packed<'Notification'>> {
const notification = src;
const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null;
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? (
hint?.packedNotes != null
? hint.packedNotes.get(notification.noteId)
: this.noteEntityService.pack(notification.noteId!, { id: meId }, {
: this.noteEntityService.pack(notification.noteId, { id: meId }, {
detail: true,
})
) : undefined;
const userIfNeed = notification.notifierId != null ? (
const userIfNeed = 'notifierId' in notification ? (
hint?.packedUsers != null
? hint.packedUsers.get(notification.notifierId)
: this.userEntityService.pack(notification.notifierId!, { id: meId }, {
: this.userEntityService.pack(notification.notifierId, { id: meId }, {
detail: false,
})
) : undefined;
@@ -89,7 +86,7 @@ export class NotificationEntityService implements OnModuleInit {
id: notification.id,
createdAt: new Date(notification.createdAt).toISOString(),
type: notification.type,
userId: notification.notifierId,
userId: 'notifierId' in notification ? notification.notifierId : undefined,
...(userIfNeed != null ? { user: userIfNeed } : {}),
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
...(notification.type === 'reaction' ? {
@@ -100,8 +97,8 @@ export class NotificationEntityService implements OnModuleInit {
} : {}),
...(notification.type === 'app' ? {
body: notification.customBody,
header: notification.customHeader ?? token?.name,
icon: notification.customIcon ?? token?.iconUrl,
header: notification.customHeader,
icon: notification.customIcon,
} : {}),
});
}
@@ -115,7 +112,7 @@ export class NotificationEntityService implements OnModuleInit {
let validNotifications = notifications;
const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull);
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
const notes = noteIds.length > 0 ? await this.notesRepository.find({
where: { id: In(noteIds) },
relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'],
@@ -125,9 +122,9 @@ export class NotificationEntityService implements OnModuleInit {
});
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId));
validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId));
const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull);
const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull);
const users = userIds.length > 0 ? await this.usersRepository.find({
where: { id: In(userIds) },
}) : [];
@@ -137,10 +134,10 @@ export class NotificationEntityService implements OnModuleInit {
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
// 既に解決されたフォローリクエストの通知を除外
const followRequestNotifications = validNotifications.filter(x => x.type === 'receiveFollowRequest');
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
if (followRequestNotifications.length > 0) {
const reqs = await this.followRequestsRepository.find({
where: { followerId: In(followRequestNotifications.map(x => x.notifierId!)) },
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
});
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
}
@@ -150,4 +147,141 @@ export class NotificationEntityService implements OnModuleInit {
packedUsers,
})));
}
@bindThis
public async packGrouped(
src: MiGroupedNotification,
meId: MiUser['id'],
// eslint-disable-next-line @typescript-eslint/ban-types
options: {
},
hint?: {
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
packedUsers: Map<MiUser['id'], Packed<'User'>>;
},
): Promise<Packed<'Notification'>> {
const notification = src;
const noteIfNeed = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? (
hint?.packedNotes != null
? hint.packedNotes.get(notification.noteId)
: this.noteEntityService.pack(notification.noteId, { id: meId }, {
detail: true,
})
) : undefined;
const userIfNeed = 'notifierId' in notification ? (
hint?.packedUsers != null
? hint.packedUsers.get(notification.notifierId)
: this.userEntityService.pack(notification.notifierId, { id: meId }, {
detail: false,
})
) : undefined;
if (notification.type === 'reaction:grouped') {
const reactions = await Promise.all(notification.reactions.map(async reaction => {
const user = hint?.packedUsers != null
? hint.packedUsers.get(reaction.userId)!
: await this.userEntityService.pack(reaction.userId, { id: meId }, {
detail: false,
});
return {
user,
reaction: reaction.reaction,
};
}));
return await awaitAll({
id: notification.id,
createdAt: new Date(notification.createdAt).toISOString(),
type: notification.type,
note: noteIfNeed,
reactions,
});
} else if (notification.type === 'renote:grouped') {
const users = await Promise.all(notification.userIds.map(userId => {
const user = hint?.packedUsers != null
? hint.packedUsers.get(userId)
: this.userEntityService.pack(userId!, { id: meId }, {
detail: false,
});
return user;
}));
return await awaitAll({
id: notification.id,
createdAt: new Date(notification.createdAt).toISOString(),
type: notification.type,
note: noteIfNeed,
users,
});
}
return await awaitAll({
id: notification.id,
createdAt: new Date(notification.createdAt).toISOString(),
type: notification.type,
userId: 'notifierId' in notification ? notification.notifierId : undefined,
...(userIfNeed != null ? { user: userIfNeed } : {}),
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
...(notification.type === 'reaction' ? {
reaction: notification.reaction,
} : {}),
...(notification.type === 'achievementEarned' ? {
achievement: notification.achievement,
} : {}),
...(notification.type === 'app' ? {
body: notification.customBody,
header: notification.customHeader,
icon: notification.customIcon,
} : {}),
});
}
@bindThis
public async packGroupedMany(
notifications: MiGroupedNotification[],
meId: MiUser['id'],
) {
if (notifications.length === 0) return [];
let validNotifications = notifications;
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
const notes = noteIds.length > 0 ? await this.notesRepository.find({
where: { id: In(noteIds) },
relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'],
}) : [];
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
detail: true,
});
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId));
const userIds = [];
for (const notification of validNotifications) {
if ('notifierId' in notification) userIds.push(notification.notifierId);
if (notification.type === 'reaction:grouped') userIds.push(...notification.reactions.map(x => x.userId));
if (notification.type === 'renote:grouped') userIds.push(...notification.userIds);
}
const users = userIds.length > 0 ? await this.usersRepository.find({
where: { id: In(userIds) },
}) : [];
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, {
detail: false,
});
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
// 既に解決されたフォローリクエストの通知を除外
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
if (followRequestNotifications.length > 0) {
const reqs = await this.followRequestsRepository.find({
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
});
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
}
return await Promise.all(validNotifications.map(x => this.packGrouped(x, meId, {}, {
packedNotes,
packedUsers,
})));
}
}

View File

@@ -15,6 +15,7 @@ import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/User.js';
import { MiNotification } from '@/models/Notification.js';
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, MiUserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
@@ -235,17 +236,34 @@ export class UserEntityService implements OnModuleInit {
}
@bindThis
public async getHasUnreadNotification(userId: MiUser['id']): Promise<boolean> {
public async getNotificationsInfo(userId: MiUser['id']): Promise<{
hasUnread: boolean;
unreadCount: number;
}> {
const response = {
hasUnread: false,
unreadCount: 0,
};
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
const latestNotificationIdsRes = await this.redisClient.xrevrange(
`notificationTimeline:${userId}`,
'+',
'-',
'COUNT', 1);
const latestNotificationId = latestNotificationIdsRes[0]?.[0];
if (!latestReadNotificationId) {
response.unreadCount = await this.redisClient.xlen(`notificationTimeline:${userId}`);
} else {
const latestNotificationIdsRes = await this.redisClient.xrevrange(
`notificationTimeline:${userId}`,
'+',
latestReadNotificationId,
);
return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId);
response.unreadCount = (latestNotificationIdsRes.length - 1 >= 0) ? latestNotificationIdsRes.length - 1 : 0;
}
if (response.unreadCount > 0) {
response.hasUnread = true;
}
return response;
}
@bindThis
@@ -331,6 +349,8 @@ export class UserEntityService implements OnModuleInit {
...announcement,
})) : null;
const notificationsInfo = isMe && opts.detail ? await this.getNotificationsInfo(user.id) : null;
const packed = {
id: user.id,
name: user.name,
@@ -449,8 +469,9 @@ export class UserEntityService implements OnModuleInit {
unreadAnnouncements,
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
hasUnreadChannel: false, // 後方互換性のため
hasUnreadNotification: this.getHasUnreadNotification(user.id),
hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
unreadNotificationsCount: notificationsInfo?.unreadCount,
mutedWords: profile!.mutedWords,
mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: [], // 後方互換性のため

View File

@@ -0,0 +1,10 @@
import type { MiNote } from '@/models/Note.js';
export function isPureRenote(note: MiNote): note is MiNote & { renoteId: NonNullable<MiNote['renoteId']> } {
if (!note.renoteId) return false;
if (note.text) return false; // it's quoted with text
if (note.fileIds.length !== 0) return false; // it's quoted with files
if (note.hasPoll) return false; // it's quoted with poll
return true;
}

View File

@@ -10,30 +10,73 @@ import { MiFollowRequest } from './FollowRequest.js';
import { MiAccessToken } from './AccessToken.js';
export type MiNotification = {
type: 'note';
id: string;
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
} | {
type: 'follow';
id: string;
createdAt: string;
notifierId: MiUser['id'];
} | {
type: 'mention';
id: string;
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
} | {
type: 'reply';
id: string;
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
} | {
type: 'renote';
id: string;
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
targetNoteId: MiNote['id'];
} | {
type: 'quote';
id: string;
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
} | {
type: 'reaction';
id: string;
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
reaction: string;
} | {
type: 'pollEnded';
id: string;
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
} | {
type: 'receiveFollowRequest';
id: string;
createdAt: string;
notifierId: MiUser['id'];
} | {
type: 'followRequestAccepted';
id: string;
createdAt: string;
notifierId: MiUser['id'];
} | {
type: 'achievementEarned';
id: string;
createdAt: string;
achievement: string;
} | {
type: 'app';
id: string;
// RedisのためDateではなくstring
createdAt: string;
/**
* 通知の送信者(initiator)
*/
notifierId: MiUser['id'] | null;
/**
* 通知の種類。
*/
type: typeof notificationTypes[number];
noteId: MiNote['id'] | null;
followRequestId: MiFollowRequest['id'] | null;
reaction: string | null;
choice: number | null;
achievement: string | null;
/**
* アプリ通知のbody
@@ -56,4 +99,25 @@ export type MiNotification = {
* アプリ通知のアプリ(のトークン)
*/
appAccessTokenId: MiAccessToken['id'] | null;
}
} | {
type: 'test';
id: string;
createdAt: string;
};
export type MiGroupedNotification = MiNotification | {
type: 'reaction:grouped';
id: string;
createdAt: string;
noteId: MiNote['id'];
reactions: {
userId: string;
reaction: string;
}[];
} | {
type: 'renote:grouped';
id: string;
createdAt: string;
noteId: MiNote['id'];
userIds: string[];
};

View File

@@ -12,7 +12,6 @@ export const packedNotificationSchema = {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string',
@@ -22,7 +21,7 @@ export const packedNotificationSchema = {
type: {
type: 'string',
optional: false, nullable: false,
enum: [...notificationTypes],
enum: [...notificationTypes, 'reaction:grouped', 'renote:grouped'],
},
user: {
type: 'object',
@@ -63,5 +62,33 @@ export const packedNotificationSchema = {
type: 'string',
optional: true, nullable: true,
},
reactions: {
type: 'array',
optional: true, nullable: true,
items: {
type: 'object',
properties: {
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
reaction: {
type: 'string',
optional: false, nullable: false,
},
},
required: ['user', 'reaction'],
},
},
},
users: {
type: 'array',
optional: true, nullable: true,
items: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
},
} as const;

View File

@@ -399,6 +399,10 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
unreadNotificationsCount: {
type: 'number',
nullable: false, optional: false,
},
mutedWords: {
type: 'array',
nullable: false, optional: false,

View File

@@ -26,6 +26,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { IActivity } from '@/core/activitypub/type.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import type { FindOptionsWhere } from 'typeorm';
@@ -88,7 +89,7 @@ export class ActivityPubServerService {
*/
@bindThis
private async packActivity(note: MiNote): Promise<any> {
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
if (isPureRenote(note)) {
const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note);
}

View File

@@ -217,6 +217,7 @@ import * as ep___i_importMuting from './endpoints/i/import-muting.js';
import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js';
import * as ep___i_importAntennas from './endpoints/i/import-antennas.js';
import * as ep___i_notifications from './endpoints/i/notifications.js';
import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js';
import * as ep___i_pageLikes from './endpoints/i/page-likes.js';
import * as ep___i_pages from './endpoints/i/pages.js';
import * as ep___i_pin from './endpoints/i/pin.js';
@@ -574,6 +575,7 @@ const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep_
const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default };
const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default };
const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default };
const $i_notificationsGrouped: Provider = { provide: 'ep:i/notifications-grouped', useClass: ep___i_notificationsGrouped.default };
const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default };
const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default };
const $i_pin: Provider = { provide: 'ep:i/pin', useClass: ep___i_pin.default };
@@ -935,6 +937,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_importUserLists,
$i_importAntennas,
$i_notifications,
$i_notificationsGrouped,
$i_pageLikes,
$i_pages,
$i_pin,
@@ -1290,6 +1293,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_importUserLists,
$i_importAntennas,
$i_notifications,
$i_notificationsGrouped,
$i_pageLikes,
$i_pages,
$i_pin,

View File

@@ -217,6 +217,7 @@ import * as ep___i_importMuting from './endpoints/i/import-muting.js';
import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js';
import * as ep___i_importAntennas from './endpoints/i/import-antennas.js';
import * as ep___i_notifications from './endpoints/i/notifications.js';
import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js';
import * as ep___i_pageLikes from './endpoints/i/page-likes.js';
import * as ep___i_pages from './endpoints/i/pages.js';
import * as ep___i_pin from './endpoints/i/pin.js';
@@ -572,6 +573,7 @@ const eps = [
['i/import-user-lists', ep___i_importUserLists],
['i/import-antennas', ep___i_importAntennas],
['i/notifications', ep___i_notifications],
['i/notifications-grouped', ep___i_notificationsGrouped],
['i/page-likes', ep___i_pageLikes],
['i/pages', ep___i_pages],
['i/pin', ep___i_pin],

View File

@@ -11,7 +11,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireRolePolicy: 'canManageAvatarDecorations',
} as const;
export const paramDef = {

View File

@@ -13,8 +13,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireRolePolicy: 'canManageAvatarDecorations',
errors: {
},
} as const;

View File

@@ -16,7 +16,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireRolePolicy: 'canManageAvatarDecorations',
res: {
type: 'array',

View File

@@ -13,7 +13,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireRolePolicy: 'canManageAvatarDecorations',
errors: {
},

View File

@@ -0,0 +1,178 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Brackets, In } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { MiGroupedNotification, MiNotification } from '@/models/Notification.js';
export const meta = {
tags: ['account', 'notifications'],
requireCredential: true,
limit: {
duration: 30000,
max: 30,
},
kind: 'read:notifications',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Notification',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
markAsRead: { type: 'boolean', default: true },
// 後方互換のため、廃止された通知タイプも受け付ける
includeTypes: { type: 'array', items: {
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
} },
excludeTypes: { type: 'array', items: {
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
} },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private idService: IdService,
private notificationEntityService: NotificationEntityService,
private notificationService: NotificationService,
private noteReadService: NoteReadService,
) {
super(meta, paramDef, async (ps, me) => {
const EXTRA_LIMIT = 100;
// includeTypes が空の場合はクエリしない
if (ps.includeTypes && ps.includeTypes.length === 0) {
return [];
}
// excludeTypes に全指定されている場合はクエリしない
if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) {
return [];
}
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const notificationsRes = await this.redisClient.xrevrange(
`notificationTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-',
'COUNT', limit);
if (notificationsRes.length === 0) {
return [];
}
let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[];
if (includeTypes && includeTypes.length > 0) {
notifications = notifications.filter(notification => includeTypes.includes(notification.type));
} else if (excludeTypes && excludeTypes.length > 0) {
notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
}
if (notifications.length === 0) {
return [];
}
// Mark all as read
if (ps.markAsRead) {
this.notificationService.readAllNotification(me.id);
}
// grouping
let groupedNotifications = [notifications[0]] as MiGroupedNotification[];
for (let i = 1; i < notifications.length; i++) {
const notification = notifications[i];
const prev = notifications[i - 1];
let prevGroupedNotification = groupedNotifications.at(-1)!;
if (prev.type === 'reaction' && notification.type === 'reaction' && prev.noteId === notification.noteId) {
if (prevGroupedNotification.type !== 'reaction:grouped') {
groupedNotifications[groupedNotifications.length - 1] = {
type: 'reaction:grouped',
id: '',
createdAt: prev.createdAt,
noteId: prev.noteId!,
reactions: [{
userId: prev.notifierId!,
reaction: prev.reaction!,
}],
};
prevGroupedNotification = groupedNotifications.at(-1)!;
}
(prevGroupedNotification as FilterUnionByProperty<MiGroupedNotification, 'type', 'reaction:grouped'>).reactions.push({
userId: notification.notifierId!,
reaction: notification.reaction!,
});
prevGroupedNotification.id = notification.id;
continue;
}
if (prev.type === 'renote' && notification.type === 'renote' && prev.targetNoteId === notification.targetNoteId) {
if (prevGroupedNotification.type !== 'renote:grouped') {
groupedNotifications[groupedNotifications.length - 1] = {
type: 'renote:grouped',
id: '',
createdAt: notification.createdAt,
noteId: prev.noteId!,
userIds: [prev.notifierId!],
};
prevGroupedNotification = groupedNotifications.at(-1)!;
}
(prevGroupedNotification as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'>).userIds.push(notification.notifierId!);
prevGroupedNotification.id = notification.id;
continue;
}
groupedNotifications.push(notification);
}
groupedNotifications = groupedNotifications.slice(0, ps.limit);
const noteIds = groupedNotifications
.filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote'> => ['mention', 'reply', 'quote'].includes(notification.type))
.map(notification => notification.noteId!);
if (noteIds.length > 0) {
const notes = await this.notesRepository.findBy({ id: In(noteIds) });
this.noteReadService.read(me.id, notes);
}
return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id);
});
}
}

View File

@@ -7,7 +7,7 @@ import { Brackets, In } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import { obsoleteNotificationTypes, notificationTypes } from '@/types.js';
import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
@@ -113,8 +113,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
const noteIds = notifications
.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type))
.map(notification => notification.noteId!);
.filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote'> => ['mention', 'reply', 'quote'].includes(notification.type))
.map(notification => notification.noteId);
if (noteIds.length > 0) {
const notes = await this.notesRepository.findBy({ id: In(noteIds) });

View File

@@ -45,7 +45,7 @@ export const meta = {
limit: {
duration: ms('1hour'),
max: 10,
max: 20,
},
errors: {

View File

@@ -17,6 +17,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
export const meta = {
tags: ['notes'],
@@ -221,7 +222,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (renote == null) {
throw new ApiError(meta.errors.noSuchRenoteTarget);
} else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) {
} else if (isPureRenote(renote)) {
throw new ApiError(meta.errors.cannotReRenote);
}
@@ -254,7 +255,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (reply == null) {
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) {
} else if (isPureRenote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
}

View File

@@ -237,7 +237,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (followingChannels.length > 0) {
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere('note.channelId IN (:...followingChannelIds) OR note.channelId IS NULL', { followingChannelIds });
query.andWhere(new Brackets(qb => {
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
qb.orWhere('note.channelId IS NULL');
}));
} else {
query.andWhere('note.channelId IS NULL');
}

View File

@@ -5,7 +5,7 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { MiNote, NotesRepository } from '@/models/_.js';
import type { MiNote, NotesRepository, ChannelFollowingsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
@@ -58,6 +58,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
private noteEntityService: NoteEntityService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
@@ -160,22 +163,52 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({
where: {
followerId: me.id,
},
});
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.channelId IS NULL')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
if (followees.length > 0) {
if (followees.length > 0 && followingChannels.length > 0) {
// ユーザー・チャンネルともにフォローあり
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
.where(new Brackets(qb2 => {
qb2
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
.andWhere('note.channelId IS NULL');
}))
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
}));
} else if (followees.length > 0) {
// ユーザーフォローのみ(チャンネルフォローなし)
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
query
.andWhere('note.channelId IS NULL')
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
} else if (followingChannels.length > 0) {
// チャンネルフォローのみ(ユーザーフォローなし)
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
.orWhere('note.userId = :meId', { meId: me.id });
}));
} else {
query.andWhere('note.userId = :meId', { meId: me.id });
// フォローなし
query
.andWhere('note.channelId IS NULL')
.andWhere('note.userId = :meId', { meId: me.id });
}
query.andWhere(new Brackets(qb => {

View File

@@ -42,8 +42,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.notificationService.createNotification(user.id, 'app', {
appAccessTokenId: token ? token.id : null,
customBody: ps.body,
customHeader: ps.header,
customIcon: ps.icon,
customHeader: ps.header ?? token?.name ?? null,
customIcon: ps.icon ?? token?.iconUrl ?? null,
});
});
}

View File

@@ -67,6 +67,8 @@ export default abstract class Channel {
}
public abstract init(params: any): void;
public dispose?(): void;
public onMessage?(type: string, body: any): void;
}

View File

@@ -56,7 +56,7 @@ class HomeTimelineChannel extends Channel {
if (note.visibility === 'followers') {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
if (!note.visibleUserIds!.includes(this.user!.id)) return;
if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return;
}
if (note.reply) {

View File

@@ -67,7 +67,7 @@ class HybridTimelineChannel extends Channel {
if (note.visibility === 'followers') {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
if (!note.visibleUserIds!.includes(this.user!.id)) return;
if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return;
}
// Ignore notes from instances the user has muted

View File

@@ -249,3 +249,9 @@ export type Serialized<T> = {
? Serialized<T[K]>
: T[K];
};
export type FilterUnionByProperty<
Union,
Property extends string | number | symbol,
Condition
> = Union extends Record<Property, Condition> ? Union : never;

View File

@@ -720,7 +720,7 @@ describe('クリップ', () => {
test('を追加できる。', async () => {
await addNote({ clipId: aliceClip.id, noteId: aliceNote.id });
const res = await show({ clipId: aliceClip.id });
assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString());
assert.strictEqual(res.lastClippedAt, res.lastClippedAt ? new Date(res.lastClippedAt).toISOString() : null);
assert.deepStrictEqual((await notes({ clipId: aliceClip.id })).map(x => x.id), [aliceNote.id]);
// 他人の非公開ノートも突っ込める

View File

@@ -164,6 +164,7 @@ describe('ユーザー', () => {
hasUnreadAntenna: user.hasUnreadAntenna,
hasUnreadChannel: user.hasUnreadChannel,
hasUnreadNotification: user.hasUnreadNotification,
unreadNotificationsCount: user.unreadNotificationsCount,
hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
unreadAnnouncements: user.unreadAnnouncements,
mutedWords: user.mutedWords,
@@ -414,6 +415,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.hasUnreadAntenna, false);
assert.strictEqual(response.hasUnreadChannel, false);
assert.strictEqual(response.hasUnreadNotification, false);
assert.strictEqual(response.unreadNotificationsCount, 0);
assert.strictEqual(response.hasPendingReceivedFollowRequest, false);
assert.deepStrictEqual(response.unreadAnnouncements, []);
assert.deepStrictEqual(response.mutedWords, []);

View File

@@ -10,7 +10,7 @@
"build-storybook": "pnpm build-storybook-pre && storybook build",
"chromatic": "chromatic",
"test": "vitest --run",
"test-and-coverage": "vitest --run --coverage",
"test-and-coverage": "vitest --run --coverage --globals",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
"lint": "pnpm typecheck && pnpm eslint"
@@ -20,7 +20,7 @@
"@github/webauthn-json": "2.1.1",
"@rollup/plugin-alias": "5.0.1",
"@rollup/plugin-json": "6.0.1",
"@rollup/plugin-replace": "5.0.4",
"@rollup/plugin-replace": "5.0.5",
"@rollup/pluginutils": "5.0.5",
"@syuilo/aiscript": "0.16.0",
"@tabler/icons-webfont": "2.37.0",
@@ -29,7 +29,8 @@
"@vue/compiler-sfc": "3.3.7",
"astring": "1.8.6",
"autosize": "6.0.1",
"broadcast-channel": "5.5.1",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.5",
"broadcast-channel": "6.0.0",
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"buraha": "0.0.1",
"canvas-confetti": "1.6.1",
@@ -38,7 +39,7 @@
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "7.5.4",
"chromatic": "7.6.0",
"compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0",
@@ -54,11 +55,11 @@
"mfm-js": "0.23.3",
"misskey-js": "workspace:*",
"photoswipe": "5.4.2",
"prismjs": "1.29.0",
"punycode": "2.3.0",
"punycode": "2.3.1",
"querystring": "0.2.1",
"rollup": "4.1.4",
"rollup": "4.2.0",
"sanitize-html": "2.11.0",
"shiki": "^0.14.5",
"sass": "1.69.5",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
@@ -74,34 +75,33 @@
"vanilla-tilt": "1.8.1",
"vite": "4.5.0",
"vue": "3.3.7",
"vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next"
},
"devDependencies": {
"@storybook/addon-actions": "7.5.1",
"@storybook/addon-essentials": "7.5.1",
"@storybook/addon-interactions": "7.5.1",
"@storybook/addon-links": "7.5.1",
"@storybook/addon-storysource": "7.5.1",
"@storybook/addons": "7.5.1",
"@storybook/blocks": "7.5.1",
"@storybook/core-events": "7.5.1",
"@storybook/addon-actions": "7.5.2",
"@storybook/addon-essentials": "7.5.2",
"@storybook/addon-interactions": "7.5.2",
"@storybook/addon-links": "7.5.2",
"@storybook/addon-storysource": "7.5.2",
"@storybook/addons": "7.5.2",
"@storybook/blocks": "7.5.2",
"@storybook/core-events": "7.5.2",
"@storybook/jest": "0.2.3",
"@storybook/manager-api": "7.5.1",
"@storybook/preview-api": "7.5.1",
"@storybook/react": "7.5.1",
"@storybook/react-vite": "7.5.1",
"@storybook/manager-api": "7.5.2",
"@storybook/preview-api": "7.5.2",
"@storybook/react": "7.5.2",
"@storybook/react-vite": "7.5.2",
"@storybook/testing-library": "0.2.2",
"@storybook/theming": "7.5.1",
"@storybook/types": "7.5.1",
"@storybook/vue3": "7.5.1",
"@storybook/vue3-vite": "7.5.1",
"@testing-library/vue": "7.0.0",
"@storybook/theming": "7.5.2",
"@storybook/types": "7.5.2",
"@storybook/vue3": "7.5.2",
"@storybook/vue3-vite": "7.5.2",
"@testing-library/vue": "8.0.0",
"@types/escape-regexp": "0.0.2",
"@types/estree": "1.0.3",
"@types/estree": "1.0.4",
"@types/matter-js": "0.19.2",
"@types/micromatch": "4.0.4",
"@types/node": "20.8.9",
"@types/node": "20.8.10",
"@types/punycode": "2.1.1",
"@types/sanitize-html": "2.9.3",
"@types/throttle-debounce": "5.0.1",
@@ -109,13 +109,13 @@
"@types/uuid": "9.0.6",
"@types/websocket": "1.0.8",
"@types/ws": "8.5.8",
"@typescript-eslint/eslint-plugin": "6.9.0",
"@typescript-eslint/parser": "6.9.0",
"@typescript-eslint/eslint-plugin": "6.9.1",
"@typescript-eslint/parser": "6.9.1",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.7",
"acorn": "8.11.2",
"cross-env": "7.0.3",
"cypress": "13.3.3",
"cypress": "13.4.0",
"eslint": "8.52.0",
"eslint-plugin-import": "2.29.0",
"eslint-plugin-vue": "9.18.1",
@@ -129,7 +129,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.1",
"storybook": "7.5.1",
"storybook": "7.5.2",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.3",

View File

@@ -8,7 +8,7 @@ import { common } from './common.js';
import { version, ui, lang, updateLocale } from '@/config.js';
import { i18n, updateI18n } from '@/i18n.js';
import { confirm, alert, post, popup, toast } from '@/os.js';
import { useStream } from '@/stream.js';
import { useStream, isReloading } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account.js';
import { defaultStore, ColdDeviceStorage } from '@/store.js';
@@ -39,6 +39,7 @@ export async function mainBoot() {
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (isReloading) return;
if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
location.reload();
} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
@@ -225,11 +226,18 @@ export async function mainBoot() {
});
main.on('readAllNotifications', () => {
updateAccount({ hasUnreadNotification: false });
updateAccount({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
});
});
main.on('unreadNotification', () => {
updateAccount({ hasUnreadNotification: true });
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateAccount({
hasUnreadNotification: true,
unreadNotificationsCount,
});
});
main.on('unreadMention', () => {

View File

@@ -5,21 +5,90 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- eslint-disable vue/no-v-html -->
<template>
<code v-if="inline" :class="`language-${prismLang}`" style="overflow-wrap: anywhere;" v-html="html"></code>
<pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre>
<div :class="['codeBlockRoot', { 'codeEditor': codeEditor }]" v-html="html"></div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import Prism from 'prismjs';
import 'prismjs/themes/prism-okaidia.css';
import { ref, computed, watch } from 'vue';
import { BUNDLED_LANGUAGES } from 'shiki';
import type { Lang as ShikiLang } from 'shiki';
import { getHighlighter } from '@/scripts/code-highlighter.js';
const props = defineProps<{
code: string;
lang?: string;
inline?: boolean;
codeEditor?: boolean;
}>();
const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js');
const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value));
const highlighter = await getHighlighter();
const codeLang = ref<ShikiLang | 'aiscript'>('js');
const html = computed(() => highlighter.codeToHtml(props.code, {
lang: codeLang.value,
theme: 'dark-plus',
}));
async function fetchLanguage(to: string): Promise<void> {
const language = to as ShikiLang;
// Check for the loaded languages, and load the language if it's not loaded yet.
if (!highlighter.getLoadedLanguages().includes(language)) {
// Check if the language is supported by Shiki
const bundles = BUNDLED_LANGUAGES.filter((bundle) => {
// Languages are specified by their id, they can also have aliases (i. e. "js" and "javascript")
return bundle.id === language || bundle.aliases?.includes(language);
});
if (bundles.length > 0) {
await highlighter.loadLanguage(language);
codeLang.value = language;
} else {
codeLang.value = 'js';
}
} else {
codeLang.value = language;
}
}
watch(() => props.lang, (to) => {
if (codeLang.value === to || !to) return;
return new Promise((resolve) => {
fetchLanguage(to).then(() => resolve);
});
}, { immediate: true, });
</script>
<style scoped lang="scss">
.codeBlockRoot :deep(.shiki) {
padding: 1em;
margin: .5em 0;
overflow: auto;
border-radius: .3em;
& pre,
& code {
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
}
}
.codeBlockRoot.codeEditor {
min-width: 100%;
height: 100%;
& :deep(.shiki) {
padding: 12px;
margin: 0;
border-radius: 6px;
min-height: 130px;
pointer-events: none;
min-width: calc(100% - 24px);
height: 100%;
display: inline-block;
line-height: 1.5em;
font-size: 1em;
overflow: visible;
text-rendering: inherit;
text-transform: inherit;
white-space: pre;
}
}
</style>

View File

@@ -4,11 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XCode :code="code" :lang="lang" :inline="inline"/>
<Suspense>
<template #fallback>
<MkLoading v-if="!inline ?? true" />
</template>
<code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code>
<XCode v-else :code="code" :lang="lang"/>
</Suspense>
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
import MkLoading from '@/components/global/MkLoading.vue';
defineProps<{
code: string;
@@ -18,3 +25,15 @@ defineProps<{
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
</script>
<style module lang="scss">
.codeInlineRoot {
display: inline-block;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
overflow-wrap: anywhere;
color: #D4D4D4;
background: #1E1E1E;
padding: .1em;
border-radius: .3em;
}
</style>

View File

@@ -0,0 +1,166 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.codeEditorRoot, { [$style.disabled]: disabled, [$style.focused]: focused }]">
<div :class="$style.codeEditorScroller">
<textarea
ref="inputEl"
v-model="vModel"
:class="[$style.textarea]"
:disabled="disabled"
:required="required"
:readonly="readonly"
autocomplete="off"
wrap="off"
spellcheck="false"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@input="onInput"
></textarea>
<XCode :class="$style.codeEditorHighlighter" :codeEditor="true" :code="v" :lang="lang"/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, toRefs, shallowRef, nextTick } from 'vue';
import XCode from '@/components/MkCode.core.vue';
const props = withDefaults(defineProps<{
modelValue: string | null;
lang: string;
required?: boolean;
readonly?: boolean;
disabled?: boolean;
}>(), {
lang: 'js',
});
const emit = defineEmits<{
(ev: 'change', _ev: KeyboardEvent): void;
(ev: 'keydown', _ev: KeyboardEvent): void;
(ev: 'enter'): void;
(ev: 'update:modelValue', value: string): void;
}>();
const { modelValue } = toRefs(props);
const vModel = ref<string>(modelValue.value ?? '');
const v = ref<string>(modelValue.value ?? '');
const focused = ref(false);
const changed = ref(false);
const inputEl = shallowRef<HTMLTextAreaElement>();
const onInput = (ev) => {
v.value = ev.target?.value ?? v.value;
changed.value = true;
emit('change', ev);
};
const onKeydown = (ev: KeyboardEvent) => {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
emit('keydown', ev);
if (ev.code === 'Enter') {
const pos = inputEl.value?.selectionStart ?? 0;
const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length;
if (pos === posEnd) {
const lines = vModel.value.slice(0, pos).split('\n');
const currentLine = lines[lines.length - 1];
const currentLineSpaces = currentLine.match(/^\s+/);
const posDelta = currentLineSpaces ? currentLineSpaces[0].length : 0;
ev.preventDefault();
vModel.value = vModel.value.slice(0, pos) + '\n' + (currentLineSpaces ? currentLineSpaces[0] : '') + vModel.value.slice(pos);
v.value = vModel.value;
nextTick(() => {
inputEl.value?.setSelectionRange(pos + 1 + posDelta, pos + 1 + posDelta);
});
}
emit('enter');
}
if (ev.key === 'Tab') {
const pos = inputEl.value?.selectionStart ?? 0;
const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length;
vModel.value = vModel.value.slice(0, pos) + '\t' + vModel.value.slice(posEnd);
v.value = vModel.value;
nextTick(() => {
inputEl.value?.setSelectionRange(pos + 1, pos + 1);
});
ev.preventDefault();
}
};
const updated = () => {
changed.value = false;
emit('update:modelValue', v.value);
};
watch(modelValue, newValue => {
v.value = newValue ?? '';
});
watch(v, () => {
updated();
});
</script>
<style lang="scss" module>
.codeEditorRoot {
min-width: 100%;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
margin: 0;
padding: 0;
color: var(--fg);
border: solid 1px var(--panel);
transition: border-color 0.1s ease-out;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
&:hover {
border-color: var(--inputBorderHover) !important;
}
}
.focused.codeEditorRoot {
border-color: var(--accent) !important;
border-radius: 6px;
}
.codeEditorScroller {
position: relative;
display: inline-block;
min-width: 100%;
height: 100%;
}
.textarea {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: inline-block;
appearance: none;
resize: none;
text-align: left;
color: transparent;
caret-color: rgb(225, 228, 232);
background-color: transparent;
border: 0;
outline: 0;
padding: 12px;
line-height: 1.5em;
font-size: 1em;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
}
.textarea::selection {
color: #fff;
}
</style>

View File

@@ -42,6 +42,7 @@ export default defineComponent({
setup(props, { slots, expose }) {
const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫
function getDateText(time: string) {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
@@ -121,6 +122,7 @@ export default defineComponent({
el.style.top = `${el.offsetTop}px`;
el.style.left = `${el.offsetLeft}px`;
}
function onLeaveCanceled(el: HTMLElement) {
el.style.top = '';
el.style.left = '';

View File

@@ -160,6 +160,7 @@ async function ok() {
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();

View File

@@ -505,6 +505,7 @@ function appendFile(file: Misskey.entities.DriveFile) {
function appendFolder(folderToAppend: Misskey.entities.DriveFolder) {
addFolder(folderToAppend);
}
/*
function prependFile(file: Misskey.entities.DriveFile) {
addFile(file, true);

View File

@@ -84,6 +84,7 @@ onMounted(() => {
return getParentBg(el.parentElement);
}
}
const rawBg = getParentBg(el.value);
const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
_bg.setAlpha(0.85);

View File

@@ -7,16 +7,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
<div class="main">
<template v-for="item in items">
<template v-for="item in items" :key="item.text">
<button v-if="item.action" v-click-anime class="_button item" @click="$event => { item.action($event); close(); }">
<i class="icon" :class="item.icon"></i>
<div class="text">{{ item.text }}</div>
<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
<span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span>
<span v-else-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
</button>
<MkA v-else v-click-anime :to="item.to" class="item" @click.passive="close()">
<i class="icon" :class="item.icon"></i>
<div class="text">{{ item.text }}</div>
<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
<span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span>
<span v-else-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
</MkA>
</template>
</div>
@@ -27,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import MkModal from '@/components/MkModal.vue';
import { navbarItemDef } from '@/navbar';
import { navbarItemDef } from '@/navbar.js';
import { defaultStore } from '@/store.js';
import { deviceKind } from '@/scripts/device-kind.js';
@@ -57,6 +59,7 @@ const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k =>
to: def.to,
action: def.action,
indicate: def.indicated,
indicateValue: def.indicateValue,
}));
function close() {
@@ -116,6 +119,17 @@ function close() {
line-height: 1.5em;
}
> .indicatorWithValue {
position: absolute;
top: 32px;
left: 16px;
@media (max-width: 500px) {
top: 16px;
left: 8px;
}
}
> .indicator {
position: absolute;
top: 32px;

View File

@@ -145,11 +145,13 @@ const onGlobalMousedown = (event: MouseEvent) => {
};
let childCloseTimer: null | number = null;
function onItemMouseEnter(item) {
childCloseTimer = window.setTimeout(() => {
closeChild();
}, 300);
}
function onItemMouseLeave(item) {
if (childCloseTimer) window.clearTimeout(childCloseTimer);
}

View File

@@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm v-if="appearNote.text" :parsedNodes="parsed" :text="appearNote.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
@@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<MkReactionsViewer :note="appearNote" :maxNumber="16">
<MkReactionsViewer v-show="appearNote.cw == null || showContent" :note="appearNote" :maxNumber="16">
<template #more>
<div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div>
</template>
@@ -209,8 +209,9 @@ const clipButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const isLong = shouldCollapsed(appearNote);
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed) : null;
const isLong = shouldCollapsed(appearNote, urls ?? []);
const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false);
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);

View File

@@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<Mfm v-if="appearNote.text" :parsedNodes="parsed" :text="appearNote.text" :author="appearNote.user" :nyaize="'account'" :i="$i" :emojiUrls="appearNote.emojis"/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
@@ -260,7 +260,8 @@ const isDeleted = ref(false);
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const translation = ref(null);
const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed) : null;
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]);
@@ -499,6 +500,7 @@ function blur() {
}
const repliesLoaded = ref(false);
function loadReplies() {
repliesLoaded.value = true;
os.api('notes/children', {
@@ -510,6 +512,7 @@ function loadReplies() {
}
const conversationLoaded = ref(false);
function loadConversation() {
conversationLoaded.value = true;
os.api('notes/conversation', {

View File

@@ -9,9 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="notification.type === 'note'" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/>
<img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/>
<img v-else-if="notification.icon" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
<div
:class="[$style.subIcon, {
[$style.t_follow]: notification.type === 'follow',
@@ -39,7 +41,6 @@ SPDX-License-Identifier: AGPL-3.0-only
v-else-if="notification.type === 'reaction'"
ref="reactionRef"
:reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
:customEmojis="notification.note.emojis"
:noStyle="true"
style="width: 100%; height: 100%;"
/>
@@ -52,16 +53,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.t('_notification.reactedBySomeUsers', { n: notification.reactions.length }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.t('_notification.renotedBySomeUsers', { n: notification.users.length }) }}</span>
<span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header>
<div>
<MkA v-if="notification.type === 'reaction'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<MkA v-if="notification.type === 'reaction' || notification.type === 'reaction:grouped'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote" :class="$style.quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ti ti-quote" :class="$style.quote"></i>
</MkA>
<MkA v-else-if="notification.type === 'renote'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
<MkA v-else-if="notification.type === 'renote' || notification.type === 'renote:grouped'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
<i class="ti ti-quote" :class="$style.quote"></i>
<Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :author="notification.note.renote.user"/>
<i class="ti ti-quote" :class="$style.quote"></i>
@@ -102,6 +105,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'app'" :class="$style.text">
<Mfm :text="notification.body" :nowrap="false"/>
</span>
<div v-if="notification.type === 'reaction:grouped'">
<div v-for="reaction of notification.reactions" :class="$style.reactionsItem">
<MkAvatar :class="$style.reactionsItemAvatar" :user="reaction.user" link preview/>
<div :class="$style.reactionsItemReaction">
<MkReactionIcon
:reaction="reaction.reaction ? reaction.reaction.replace(/^:(\w+):$/, ':$1@.:') : reaction.reaction"
:noStyle="true"
style="width: 100%; height: 100%;"
/>
</div>
</div>
</div>
<div v-else-if="notification.type === 'renote:grouped'">
<div v-for="user of notification.users" :class="$style.reactionsItem">
<MkAvatar :class="$style.reactionsItemAvatar" :user="user" link preview/>
</div>
</div>
</div>
</div>
</div>
@@ -181,6 +202,29 @@ useTooltip(reactionRef, (showing) => {
display: block;
width: 100%;
height: 100%;
}
.icon_reactionGroup,
.icon_renoteGroup {
display: grid;
align-items: center;
justify-items: center;
width: 80%;
height: 80%;
font-size: 15px;
border-radius: 100%;
color: #fff;
}
.icon_reactionGroup {
background: #e99a0b;
}
.icon_renoteGroup {
background: #36d298;
}
.icon_app {
border-radius: 6px;
}
@@ -305,6 +349,36 @@ useTooltip(reactionRef, (showing) => {
flex: 1;
}
.reactionsItem {
display: inline-block;
position: relative;
width: 38px;
height: 38px;
margin-top: 8px;
margin-right: 8px;
}
.reactionsItemAvatar {
width: 100%;
height: 100%;
}
.reactionsItemReaction {
position: absolute;
z-index: 1;
bottom: -2px;
right: -2px;
width: 20px;
height: 20px;
box-sizing: border-box;
border-radius: 100%;
background: var(--panel);
box-shadow: 0 0 0 3px var(--panel);
font-size: 11px;
text-align: center;
color: #fff;
}
@container (max-width: 600px) {
.root {
padding: 16px;

View File

@@ -15,14 +15,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items: notifications }">
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/>
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/>
</MkDateSeparatedList>
</template>
</MkPagination>
</template>
<script lang="ts" setup>
import { onUnmounted, onMounted, computed, shallowRef } from 'vue';
import { onUnmounted, onDeactivated, onMounted, computed, shallowRef } from 'vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import XNotification from '@/components/MkNotification.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
@@ -32,6 +32,7 @@ import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { notificationTypes } from '@/const.js';
import { infoImageUrl } from '@/instance.js';
import { defaultStore } from '@/store.js';
const props = defineProps<{
excludeTypes?: typeof notificationTypes[number][];
@@ -39,7 +40,13 @@ const props = defineProps<{
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
const pagination: Paging = {
const pagination: Paging = defaultStore.state.useGroupedNotifications ? {
endpoint: 'i/notifications-grouped' as const,
limit: 20,
params: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
} : {
endpoint: 'i/notifications' as const,
limit: 20,
params: computed(() => ({
@@ -68,6 +75,10 @@ onMounted(() => {
onUnmounted(() => {
if (connection) connection.dispose();
});
onDeactivated(() => {
if (connection) connection.dispose();
});
</script>
<style lang="scss" module>

View File

@@ -166,6 +166,8 @@ defineExpose({
<style lang="scss" module>
.root {
overscroll-behavior: none;
min-height: 100%;
background: var(--bg);

View File

@@ -87,6 +87,7 @@ function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
return new Map([...map, ...arrayToEntries(entities)]);
}
</script>
<script lang="ts" setup>
import { infoImageUrl } from '@/instance.js';
@@ -101,6 +102,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'queue', count: number): void;
(ev: 'status', error: boolean): void;
}>();
let rootEl = $shallowRef<HTMLElement>();
@@ -192,6 +194,11 @@ watch(queue, (a, b) => {
emit('queue', queue.value.size);
}, { deep: true });
watch(error, (n, o) => {
if (n === o) return;
emit('status', n);
});
async function init(): Promise<void> {
items.value = new Map();
queue.value = new Map();

View File

@@ -59,6 +59,7 @@ function toggleSensitive(file) {
emit('changeSensitive', file, !file.isSensitive);
});
}
async function rename(file) {
const { canceled, result } = await os.inputText({
title: i18n.ts.enterFileName,

View File

@@ -0,0 +1,240 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl">
<div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`">
<div :class="$style.frameContent">
<MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/>
<i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i>
<div :class="$style.text">
<template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template>
<template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template>
<template v-else>{{ i18n.ts.pullDownToRefresh }}</template>
</div>
</div>
</div>
<div :class="{ [$style.slotClip]: isPullStart }">
<slot/>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue';
import { deviceKind } from '@/scripts/device-kind.js';
import { i18n } from '@/i18n.js';
const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity;
const FIRE_THRESHOLD = 230;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 2;
const PULL_BRAKE_FACTOR = 200;
let isPullStart = $ref(false);
let isPullEnd = $ref(false);
let isRefreshing = $ref(false);
let pullDistance = $ref(0);
let supportPointerDesktop = false;
let startScreenY: number | null = null;
const rootEl = $shallowRef<HTMLDivElement>();
let scrollEl: HTMLElement | null = null;
let disabled = false;
const emits = defineEmits<{
(ev: 'refresh'): void;
}>();
function getScrollableParentElement(node) {
if (node == null) {
return null;
}
if (node.scrollHeight > node.clientHeight) {
return node;
} else {
return getScrollableParentElement(node.parentNode);
}
}
function getScreenY(event) {
if (supportPointerDesktop) {
return event.screenY;
}
return event.touches[0].screenY;
}
function moveStart(event) {
if (!isPullStart && !isRefreshing && !disabled) {
isPullStart = true;
startScreenY = getScreenY(event);
pullDistance = 0;
}
}
function moveBySystem(to: number): Promise<void> {
return new Promise(r => {
const startHeight = pullDistance;
const overHeight = pullDistance - to;
if (overHeight < 1) {
r();
return;
}
const startTime = Date.now();
let intervalId = setInterval(() => {
const time = Date.now() - startTime;
if (time > RELEASE_TRANSITION_DURATION) {
pullDistance = to;
clearInterval(intervalId);
r();
return;
}
const nextHeight = startHeight - (overHeight / RELEASE_TRANSITION_DURATION) * time;
if (pullDistance < nextHeight) return;
pullDistance = nextHeight;
}, 1);
});
}
async function fixOverContent() {
if (pullDistance > FIRE_THRESHOLD) {
await moveBySystem(FIRE_THRESHOLD);
}
}
async function closeContent() {
if (pullDistance > 0) {
await moveBySystem(0);
}
}
function moveEnd() {
if (isPullStart && !isRefreshing) {
startScreenY = null;
if (isPullEnd) {
isPullEnd = false;
isRefreshing = true;
fixOverContent().then(() => emits('refresh'));
} else {
closeContent().then(() => isPullStart = false);
}
}
}
function moving(event) {
if (!isPullStart || isRefreshing || disabled) return;
if (!scrollEl) {
scrollEl = getScrollableParentElement(rootEl);
}
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance)) {
pullDistance = 0;
isPullEnd = false;
moveEnd();
return;
}
if (startScreenY === null) {
startScreenY = getScreenY(event);
}
const moveScreenY = getScreenY(event);
const moveHeight = moveScreenY - startScreenY!;
pullDistance = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
isPullEnd = pullDistance >= FIRE_THRESHOLD;
}
/**
* emit(refresh)が完了したことを知らせる関数
*
* タイムアウトがないのでこれを最終的に実行しないと出たままになる
*/
function refreshFinished() {
closeContent().then(() => {
isPullStart = false;
isRefreshing = false;
});
}
function setDisabled(value) {
disabled = value;
}
onMounted(() => {
// マウス操作でpull to refreshするのは不便そう
//supportPointerDesktop = !!window.PointerEvent && deviceKind === 'desktop';
if (supportPointerDesktop) {
rootEl.addEventListener('pointerdown', moveStart);
// ポインターの場合、ポップアップ系の動作をするとdownだけ発火されてupが発火されないため
window.addEventListener('pointerup', moveEnd);
rootEl.addEventListener('pointermove', moving, { passive: true });
} else {
rootEl.addEventListener('touchstart', moveStart);
rootEl.addEventListener('touchend', moveEnd);
rootEl.addEventListener('touchmove', moving, { passive: true });
}
});
onUnmounted(() => {
if (supportPointerDesktop) window.removeEventListener('pointerup', moveEnd);
});
defineExpose({
refreshFinished,
setDisabled,
});
</script>
<style lang="scss" module>
.frame {
position: relative;
overflow: clip;
width: 100%;
min-height: var(--frame-min-height, 0px);
mask-image: linear-gradient(90deg, #000 0%, #000 80%, transparent);
-webkit-mask-image: -webkit-linear-gradient(90deg, #000 0%, #000 80%, transparent);
pointer-events: none;
}
.frameContent {
position: absolute;
bottom: 0;
width: 100%;
margin: 5px 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 14px;
> .icon, > .loader {
margin: 6px 0;
}
> .icon {
transition: transform .25s;
&.refresh {
transform: rotate(180deg);
}
}
> .text {
margin: 5px 0;
}
}
.slotClip {
overflow-y: clip;
}
</style>

View File

@@ -42,7 +42,7 @@ const props = defineProps<{
note: Misskey.entities.Note;
}>();
const isLong = shouldCollapsed(props.note);
const isLong = shouldCollapsed(props.note, []);
const collapsed = $ref(isLong);
</script>

View File

@@ -4,13 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
<MkPullToRefresh ref="prComponent" @refresh="() => reloadTimeline(true)">
<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)" @status="prComponent.setDisabled($event)"/>
</MkPullToRefresh>
</template>
<script lang="ts" setup>
import { computed, provide, onUnmounted } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
import { useStream } from '@/stream.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { useStream, reloadStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.js';
import { instance } from '@/instance.js';
@@ -39,6 +42,7 @@ const emit = defineEmits<{
provide('inChannel', computed(() => props.src === 'channel'));
const prComponent: InstanceType<typeof MkPullToRefresh> = $ref();
const tlComponent: InstanceType<typeof MkNotes> = $ref();
let tlNotesCount = 0;
@@ -65,29 +69,73 @@ let connection;
let connection2;
const stream = useStream();
const connectChannel = () => {
if (props.src === 'antenna') {
connection = stream.useChannel('antenna', {
antennaId: props.antenna,
});
} else if (props.src === 'home') {
connection = stream.useChannel('homeTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
connection2 = stream.useChannel('main');
} else if (props.src === 'local') {
connection = stream.useChannel('localTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
});
} else if (props.src === 'social') {
connection = stream.useChannel('hybridTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
});
} else if (props.src === 'global') {
connection = stream.useChannel('globalTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
} else if (props.src === 'mentions') {
connection = stream.useChannel('main');
connection.on('mention', prepend);
} else if (props.src === 'directs') {
const onNote = note => {
if (note.visibility === 'specified') {
prepend(note);
}
};
connection = stream.useChannel('main');
connection.on('mention', onNote);
} else if (props.src === 'list') {
connection = stream.useChannel('userList', {
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
});
} else if (props.src === 'channel') {
connection = stream.useChannel('channel', {
channelId: props.channel,
});
} else if (props.src === 'role') {
connection = stream.useChannel('roleTimeline', {
roleId: props.role,
});
}
if (props.src !== 'directs' || props.src !== 'mentions') connection.on('note', prepend);
};
if (props.src === 'antenna') {
endpoint = 'antennas/notes';
query = {
antennaId: props.antenna,
};
connection = stream.useChannel('antenna', {
antennaId: props.antenna,
});
connection.on('note', prepend);
} else if (props.src === 'home') {
endpoint = 'notes/timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
};
connection = stream.useChannel('homeTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
connection.on('note', prepend);
connection2 = stream.useChannel('main');
} else if (props.src === 'local') {
endpoint = 'notes/local-timeline';
query = {
@@ -95,12 +143,6 @@ if (props.src === 'antenna') {
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
};
connection = stream.useChannel('localTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
});
connection.on('note', prepend);
} else if (props.src === 'social') {
endpoint = 'notes/hybrid-timeline';
query = {
@@ -108,68 +150,44 @@ if (props.src === 'antenna') {
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
};
connection = stream.useChannel('hybridTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
});
connection.on('note', prepend);
} else if (props.src === 'global') {
endpoint = 'notes/global-timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
};
connection = stream.useChannel('globalTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
connection.on('note', prepend);
} else if (props.src === 'mentions') {
endpoint = 'notes/mentions';
connection = stream.useChannel('main');
connection.on('mention', prepend);
} else if (props.src === 'directs') {
endpoint = 'notes/mentions';
query = {
visibility: 'specified',
};
const onNote = note => {
if (note.visibility === 'specified') {
prepend(note);
}
};
connection = stream.useChannel('main');
connection.on('mention', onNote);
} else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline';
query = {
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
};
connection = stream.useChannel('userList', {
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
});
connection.on('note', prepend);
} else if (props.src === 'channel') {
endpoint = 'channels/timeline';
query = {
channelId: props.channel,
};
connection = stream.useChannel('channel', {
channelId: props.channel,
});
connection.on('note', prepend);
} else if (props.src === 'role') {
endpoint = 'roles/notes';
query = {
roleId: props.role,
};
connection = stream.useChannel('roleTimeline', {
roleId: props.role,
}
if (!defaultStore.state.disableStreamingTimeline) {
connectChannel();
onUnmounted(() => {
connection.dispose();
if (connection2) connection2.dispose();
});
connection.on('note', prepend);
}
const pagination = {
@@ -178,9 +196,19 @@ const pagination = {
params: query,
};
onUnmounted(() => {
connection.dispose();
if (connection2) connection2.dispose();
const reloadTimeline = (fromPR = false) => {
tlNotesCount = 0;
tlComponent.pagingComponent?.reload().then(() => {
reloadStream();
if (fromPR) prComponent.refreshFinished();
});
};
//const pullRefresh = () => reloadTimeline(true);
defineExpose({
reloadTimeline,
});
/* TODO

View File

@@ -0,0 +1,32 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.spacer, defaultStore.reactiveState.darkMode.value ? $style.dark : $style.light]"></div>
</template>
<script lang="ts" setup>
import { defaultStore } from '@/store.js';
</script>
<style lang="scss" module>
.spacer {
box-sizing: border-box;
padding: 32px;
margin: 0 auto;
height: 300px;
background-clip: content-box;
background-size: auto auto;
background-color: rgba(255, 255, 255, 0);
&.light {
background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #00000010 16px, #00000010 20px );
}
&.dark {
background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #FFFFFF16 16px, #FFFFFF16 20px );
}
}
</style>

View File

@@ -38,6 +38,7 @@ type MfmProps = {
emojiUrls?: string[];
rootScale?: number;
nyaize: boolean | 'account';
parsedNodes?: mfm.MfmNode[] | null;
};
// eslint-disable-next-line import/no-default-export
@@ -48,7 +49,7 @@ export default function(props: MfmProps) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (props.text == null || props.text === '') return;
const rootAst = (props.plain ? mfm.parseSimple : mfm.parse)(props.text);
const rootAst = props.parsedNodes ?? (props.plain ? mfm.parseSimple : mfm.parse)(props.text);
const validTime = (t: string | null | undefined) => {
if (t == null) return null;

View File

@@ -134,9 +134,11 @@ async function enter(el: HTMLElement) {
setTimeout(renderTab, 170);
}
function afterEnter(el: HTMLElement) {
//el.style.width = '';
}
async function leave(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = elementWidth + 'px';
@@ -145,6 +147,7 @@ async function leave(el: HTMLElement) {
el.style.width = '0';
el.style.paddingLeft = '0';
}
function afterLeave(el: HTMLElement) {
el.style.width = '';
}

View File

@@ -5,7 +5,7 @@
import { App } from 'vue';
import Mfm from './global/MkMisskeyFlavoredMarkdown.ts';
import Mfm from './global/MkMisskeyFlavoredMarkdown.js';
import MkA from './global/MkA.vue';
import MkAcct from './global/MkAcct.vue';
import MkAvatar from './global/MkAvatar.vue';
@@ -16,13 +16,14 @@ import MkUserName from './global/MkUserName.vue';
import MkEllipsis from './global/MkEllipsis.vue';
import MkTime from './global/MkTime.vue';
import MkUrl from './global/MkUrl.vue';
import I18n from './global/i18n';
import I18n from './global/i18n.js';
import RouterView from './global/RouterView.vue';
import MkLoading from './global/MkLoading.vue';
import MkError from './global/MkError.vue';
import MkAd from './global/MkAd.vue';
import MkPageHeader from './global/MkPageHeader.vue';
import MkSpacer from './global/MkSpacer.vue';
import MkFooterSpacer from './global/MkFooterSpacer.vue';
import MkStickyContainer from './global/MkStickyContainer.vue';
export default function(app: App) {
@@ -50,6 +51,7 @@ export const components = {
MkAd: MkAd,
MkPageHeader: MkPageHeader,
MkSpacer: MkSpacer,
MkFooterSpacer: MkFooterSpacer,
MkStickyContainer: MkStickyContainer,
};
@@ -73,6 +75,7 @@ declare module '@vue/runtime-core' {
MkAd: typeof MkAd;
MkPageHeader: typeof MkPageHeader;
MkSpacer: typeof MkSpacer;
MkFooterSpacer: typeof MkFooterSpacer;
MkStickyContainer: typeof MkStickyContainer;
}
}

View File

@@ -66,6 +66,7 @@ export const ROLE_POLICIES = [
'inviteLimitCycle',
'inviteExpirationTime',
'canManageCustomEmojis',
'canManageAvatarDecorations',
'canSearchNotes',
'canUseTranslator',
'canHideAds',

View File

@@ -1061,7 +1061,7 @@
["💰", "moneybag", 6],
["🪙", "coin", 6],
["💳", "credit_card", 6],
["🪫", "identification_card", 6],
["🪪", "identification_card", 6],
["💎", "gem", 6],
["⚖", "balance_scale", 6],
["🧰", "toolbox", 6],

View File

@@ -6,7 +6,7 @@
import { computed, reactive } from 'vue';
import { $i } from '@/account.js';
import { miLocalStorage } from '@/local-storage.js';
import { openInstanceMenu } from '@/ui/_common_/common.js';
import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js';
import { lookup } from '@/scripts/lookup.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
@@ -19,6 +19,15 @@ export const navbarItemDef = reactive({
icon: 'ti ti-bell',
show: computed(() => $i != null),
indicated: computed(() => $i != null && $i.hasUnreadNotification),
indicateValue: computed(() => {
if (!$i || $i.unreadNotificationsCount === 0) return '';
if ($i.unreadNotificationsCount > 99) {
return '99+';
} else {
return $i.unreadNotificationsCount.toString();
}
}),
to: '/my/notifications',
},
drive: {
@@ -142,6 +151,13 @@ export const navbarItemDef = reactive({
openInstanceMenu(ev);
},
},
tools: {
title: i18n.ts.tools,
icon: 'ti ti-tool',
action: (ev) => {
openToolsMenu(ev);
},
},
reload: {
title: i18n.ts.reload,
icon: 'ti ti-refresh',

View File

@@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
<template #label>{{ i18n.ts._aboutMisskey.projectMembers }}</template>
<div :class="$style.contributors">
<a href="https://github.com/syuilo" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/4439005?v=4" :class="$style.contributorAvatar">
@@ -61,20 +61,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<img src="https://avatars.githubusercontent.com/u/20679825?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@acid-chicken</span>
</a>
<a href="https://github.com/rinsuki" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/6533808?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@rinsuki</span>
<a href="https://github.com/kakkokari-gtyih" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/67428053?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@kakkokari-gtyih</span>
</a>
<a href="https://github.com/mei23" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/30769358?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@mei23</span>
</a>
<a href="https://github.com/robflop" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/8159402?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@robflop</span>
<a href="https://github.com/taichanNE30" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/40626578?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@taichanNE30</span>
</a>
</div>
<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
<MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink>
</FormSection>
<FormSection>
<template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
@@ -95,6 +94,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<a style="display: inline-block;" class="masknetwork" title="Mask Network" href="https://mask.io/" target="_blank"><img width="180" src="https://misskey-hub.net/sponsors/masknetwork.png" alt="Mask Network"></a>
</div>
<div>
<a style="display: inline-block;" class="xserver" title="XServer" href="https://www.xserver.ne.jp/" target="_blank"><img width="180" src="https://misskey-hub.net/sponsors/xserver.png" alt="XServer"></a>
</div>
<div>
<a style="display: inline-block;" class="skeb" title="Skeb" href="https://skeb.jp/" target="_blank"><img width="180" src="https://misskey-hub.net/sponsors/skeb.svg" alt="Skeb"></a>
</div>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header>
<XHeader :actions="headerActions" :tabs="headerTabs" />
<XHeader :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkSpacer :contentMax="900">
<MkSwitch :modelValue="publishing" @update:modelValue="onChangePublishing">
@@ -14,11 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
<div>
<div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad">
<MkAd v-if="ad.url" :specify="ad" />
<MkAd v-if="ad.url" :specify="ad"/>
<MkInput v-model="ad.url" type="url">
<template #label>URL</template>
</MkInput>
<MkInput v-model="ad.imageUrl">
<MkInput v-model="ad.imageUrl" type="url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<MkRadios v-model="ad.place">
@@ -51,8 +51,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<span>
{{ i18n.ts._ad.timezoneinfo }}
<div v-for="(day, index) in daysOfWeek" :key="index">
<input :id="`ad${ad.id}-${index}`" type="checkbox" :checked="(ad.dayOfWeek & (1 << index)) !== 0"
@change="toggleDayOfWeek(ad, index)">
<input
:id="`ad${ad.id}-${index}`" type="checkbox" :checked="(ad.dayOfWeek & (1 << index)) !== 0"
@change="toggleDayOfWeek(ad, index)"
>
<label :for="`ad${ad.id}-${index}`">{{ day }}</label>
</div>
</span>
@@ -61,9 +63,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.memo }}</template>
</MkTextarea>
<div class="buttons">
<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i
class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}
<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)">
<i
class="ti ti-device-floppy"
></i> {{ i18n.ts.save }}
</MkButton>
<MkButton class="button" inline danger @click="remove(ad)">
<i class="ti ti-trash"></i> {{ i18n.ts.remove }}
</MkButton>
</div>
</div>
@@ -115,6 +121,7 @@ const onChangePublishing = (v) => {
publishing = v;
refresh();
};
// 選択された曜日(index)のビットフラグを操作する
function toggleDayOfWeek(ad, index) {
ad.dayOfWeek ^= 1 << index;
@@ -187,6 +194,7 @@ function save(ad) {
});
}
}
function more() {
os.api('admin/ad/list', { untilId: ads.reduce((acc, ad) => ad.id != null ? ad : acc).id, publishing: publishing }).then(adsResponse => {
ads = ads.concat(adsResponse.map(r => {

View File

@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTextarea v-model="announcement.text">
<template #label>{{ i18n.ts.text }}</template>
</MkTextarea>
<MkInput v-model="announcement.imageUrl">
<MkInput v-model="announcement.imageUrl" type="url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<MkRadios v-model="announcement.icon">

View File

@@ -10,12 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkInput v-model="iconUrl">
<MkInput v-model="iconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }}</template>
</MkInput>
<MkInput v-model="app192IconUrl">
<MkInput v-model="app192IconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template>
<template #caption>
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkInput>
<MkInput v-model="app512IconUrl">
<MkInput v-model="app512IconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template>
<template #caption>
@@ -37,27 +37,27 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkInput>
<MkInput v-model="bannerUrl">
<MkInput v-model="bannerUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.bannerUrl }}</template>
</MkInput>
<MkInput v-model="backgroundImageUrl">
<MkInput v-model="backgroundImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
</MkInput>
<MkInput v-model="notFoundImageUrl">
<MkInput v-model="notFoundImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.notFoundDescription }}</template>
</MkInput>
<MkInput v-model="infoImageUrl">
<MkInput v-model="infoImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.nothing }}</template>
</MkInput>
<MkInput v-model="serverErrorImageUrl">
<MkInput v-model="serverErrorImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.somethingHappened }}</template>
</MkInput>

View File

@@ -118,7 +118,7 @@ const menuDef = $computed(() => [{
}, {
icon: 'ti ti-sparkles',
text: i18n.ts.avatarDecorations,
to: '/admin/avatar-decorations',
to: '/avatar-decorations',
active: currentPage?.route.name === 'avatarDecorations',
}, {
icon: 'ti ti-whirl',

View File

@@ -20,12 +20,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<MkInput v-model="tosUrl">
<MkInput v-model="tosUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.tosUrl }}</template>
</MkInput>
<MkInput v-model="privacyPolicyUrl">
<MkInput v-model="privacyPolicyUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.privacyPolicyUrl }}</template>
</MkInput>

View File

@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="useObjectStorage">{{ i18n.ts.useObjectStorage }}</MkSwitch>
<template v-if="useObjectStorage">
<MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'">
<MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'" type="url">
<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
</MkInput>

View File

@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.color }}</template>
</MkColorInput>
<MkInput v-model="role.iconUrl">
<MkInput v-model="role.iconUrl" type="url">
<template #label>{{ i18n.ts._role.iconUrl }}</template>
</MkInput>
@@ -259,6 +259,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])">
<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template>
<template #suffix>
<span v-if="role.policies.canManageAvatarDecorations.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canManageAvatarDecorations.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canManageAvatarDecorations)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canManageAvatarDecorations.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canManageAvatarDecorations.value" :disabled="role.policies.canManageAvatarDecorations.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canManageAvatarDecorations.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
<template #suffix>

View File

@@ -79,6 +79,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])">
<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template>
<template #suffix>{{ policies.canManageAvatarDecorations ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canManageAvatarDecorations">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>

View File

@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</FormSplit>
<MkInput v-model="impressumUrl">
<MkInput v-model="impressumUrl" type="url">
<template #label>{{ i18n.ts.impressumUrl }}</template>
<template #prefix><i class="ti ti-link"></i></template>
<template #caption>{{ i18n.ts.impressumDescription }}</template>

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="_gaps">
<MkFolder v-for="avatarDecoration in avatarDecorations" :key="avatarDecoration.id ?? avatarDecoration._id" :defaultOpen="avatarDecoration.id == null">
@@ -35,7 +35,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';

View File

@@ -154,12 +154,9 @@ function save() {
if (props.channelId) {
params.channelId = props.channelId;
os.api('channels/update', params).then(() => {
os.success();
});
os.apiWithDialog('channels/update', params);
} else {
os.api('channels/create', params).then(created => {
os.success();
os.apiWithDialog('channels/create', params).then(created => {
router.push(`/channels/${created.id}`);
});
}

View File

@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-code"></i></template>
<template #label>{{ i18n.ts._play.viewSource }}</template>
<MkCode :code="flash.script" :inline="false" class="_monospace"/>
<MkCode :code="flash.script" lang="is" :inline="false" class="_monospace"/>
</MkFolder>
<div :class="$style.footer">
<Mfm :text="`By @${flash.user.username}`"/>

View File

@@ -36,8 +36,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s">
<MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
</div>
</FormSection>
@@ -171,8 +171,8 @@ async function fetch(): Promise<void> {
});
suspended = instance.isSuspended;
isBlocked = instance.isBlocked;
isSilenced = instance.isSilenced;
faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
isSilenced = instance.isSilenced;
faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
}
async function toggleBlock(): Promise<void> {
@@ -183,14 +183,16 @@ async function toggleBlock(): Promise<void> {
blockedHosts: isBlocked ? meta.blockedHosts.concat([host]) : meta.blockedHosts.filter(x => x !== host),
});
}
async function toggleSilenced(): Promise<void> {
if (!meta) throw new Error('No meta?');
if (!instance) throw new Error('No instance?');
const { host } = instance;
await os.api('admin/update-meta', {
silencedHosts: isSilenced ? meta.silencedHosts.concat([host]) : meta.silencedHosts.filter(x => x !== host),
});
if (!meta) throw new Error('No meta?');
if (!instance) throw new Error('No instance?');
const { host } = instance;
await os.api('admin/update-meta', {
silencedHosts: isSilenced ? meta.silencedHosts.concat([host]) : meta.silencedHosts.filter(x => x !== host),
});
}
async function toggleSuspend(): Promise<void> {
if (!instance) throw new Error('No instance?');
await os.api('admin/federation/update-instance', {

View File

@@ -4,46 +4,46 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkSpacer :contentMax="800">
<div :class="$style.root">
<div :class="$style.editor" class="_panel">
<PrismEditor v-model="code" class="_monospace" :class="$style.code" :highlight="highlighter" :lineNumbers="false"/>
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
</div>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkContainer v-if="root && components.length > 1" :key="uiKey" :foldable="true">
<template #header>UI</template>
<div :class="$style.ui">
<MkAsUi :component="root" :components="components" size="small"/>
<MkSpacer :contentMax="800">
<div :class="$style.root">
<div class="_gaps_s">
<div :class="$style.editor" class="_panel">
<MkCodeEditor v-model="code" lang="aiscript"/>
</div>
<MkButton primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
</div>
</MkContainer>
<MkContainer :foldable="true" class="">
<template #header>{{ i18n.ts.output }}</template>
<div :class="$style.logs">
<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
<MkContainer v-if="root && components.length > 1" :key="uiKey" :foldable="true">
<template #header>UI</template>
<div :class="$style.ui">
<MkAsUi :component="root" :components="components" size="small"/>
</div>
</MkContainer>
<MkContainer :foldable="true" class="">
<template #header>{{ i18n.ts.output }}</template>
<div :class="$style.logs">
<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
</div>
</MkContainer>
<div class="">
{{ i18n.ts.scratchpadDescription }}
</div>
</MkContainer>
<div class="">
{{ i18n.ts.scratchpadDescription }}
</div>
</div>
</MkSpacer>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
import 'prismjs';
import { highlight, languages } from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import 'prismjs/themes/prism-okaidia.css';
import { PrismEditor } from 'vue-prism-editor';
import 'vue-prism-editor/dist/prismeditor.min.css';
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
import MkContainer from '@/components/MkContainer.vue';
import MkButton from '@/components/MkButton.vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import { createAiScriptEnv } from '@/scripts/aiscript/api.js';
import * as os from '@/os.js';
import { $i } from '@/account.js';
@@ -152,10 +152,6 @@ async function run() {
}
}
function highlighter(code) {
return highlight(code, languages.js, 'javascript');
}
onDeactivated(() => {
if (aiscript) aiscript.abort();
});

View File

@@ -88,6 +88,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.notificationDisplay }}</template>
<div class="_gaps_m">
<MkSwitch v-model="useGroupedNotifications">{{ i18n.ts.useGroupedNotifications }}</MkSwitch>
<MkRadios v-model="notificationPosition">
<template #label>{{ i18n.ts.position }}</template>
<option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option>
@@ -151,6 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch>
<MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch>
<MkSwitch v-model="keepScreenOn">{{ i18n.ts.keepScreenOn }}</MkSwitch>
<MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch>
</div>
<MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@@ -253,6 +256,8 @@ const notificationPosition = computed(defaultStore.makeGetterSetter('notificatio
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies'));
const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@@ -289,6 +294,7 @@ watch([
reactionsDisplaySize,
highlightSensitiveMedia,
keepScreenOn,
disableStreamingTimeline,
], async () => {
await reloadAsk();
});
@@ -298,12 +304,14 @@ const emojiIndexLangs = ['en-US'];
function downloadEmojiIndex(lang: string) {
async function main() {
const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes;
function download() {
switch (lang) {
case 'en-US': return import('../../unicode-emoji-indexes/en-US.json').then(x => x.default);
default: throw new Error('unrecognized lang: ' + lang);
}
}
currentIndexes[lang] = await download();
await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes);
}
@@ -340,6 +348,7 @@ function removePinnedList() {
let smashCount = 0;
let smashTimer: number | null = null;
function testNotification(): void {
const notification: Misskey.entities.Notification = {
id: Math.random().toString(),

View File

@@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</MkSpacer>
<MkFooterSpacer/>
</mkstickycontainer>
</template>

View File

@@ -74,11 +74,11 @@ let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage
const userLists = await os.api('users/lists/list');
async function readAllUnreadNotes() {
await os.api('i/read-all-unread-notes');
await os.apiWithDialog('i/read-all-unread-notes');
}
async function readAllNotifications() {
await os.api('notifications/mark-all-as-read');
await os.apiWithDialog('notifications/mark-all-as-read');
}
async function updateReceiveConfig(type, value) {

View File

@@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton inline @click="copy(plugin)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
</div>
<MkCode :code="plugin.src ?? ''"/>
<MkCode :code="plugin.src ?? ''" lang="is"/>
</div>
</MkFolder>
</div>

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