Compare commits

..

84 Commits

Author SHA1 Message Date
syuilo
2d0253bc42 2023.12.0-beta.1 2023-11-27 21:05:37 +09:00
syuilo
51cf906b25 update deps 2023-11-27 21:05:20 +09:00
かっこかり
2a451ebb57 enhance(frontend): 通知音にドライブのファイルを使用できるように (#12447)
* (enhance) サウンドにドライブのファイルを使用できるように

* Update Changelog

* fix

* fix design

* fix design

* Update store.ts

* (fix) ファイル名表示

* refactor

* (refactor) better types

* operationTypeとsoundTypeの混同を防止

* (refactor)

* (fix)

* enhance jsdoc

* driveFile -> _driveFile_
2023-11-27 17:33:42 +09:00
anatawa12
8f1da036f4 style: fix lint error of 6acaded8 (#12476) 2023-11-27 15:29:39 +09:00
anatawa12
6acaded898 fix: error can be happened if animation is on and hard mute matches (#12474) 2023-11-27 14:47:25 +09:00
woxtu
01d06e7121 Fix a frontend testing script (#12471) 2023-11-27 08:06:47 +09:00
ragujp
780b120c64 fix: wake lock error in safari etc (#12464) 2023-11-26 23:35:53 +09:00
Acid Chicken (硫酸鶏)
d60f645d1d chore(frontend/MkMediaVideo): loop and autoplay silent videos (#12392) 2023-11-26 16:15:24 +09:00
おさむのひと
c9503da8f8 サウンド設定に「サウンドを出力しない」と「Misskeyがアクティブな時のみサウンドを出力する」を追加 (#12342)
Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-11-26 16:12:02 +09:00
Acid Chicken (硫酸鶏)
ccb951f11e chore: create AudioContext when it is needed (#12460) 2023-11-26 14:38:34 +09:00
かっこかり
755ca97857 fix(frontend): 通知音がほぼ同時に鳴った場合は再生をブロックするように(音割れ防止) (#12433)
* (fix) 通知音がダブって音割れしないように

* Update Changelog
2023-11-26 13:20:46 +09:00
かっこかり
5bdae9f6d0 enhance(frontend): リアクション選択時に音を流せるように (#12441)
* (add) リアクション選択時に音を鳴らせるように

* Update Changelog

* tweak sound

* tweak sound

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-11-26 13:04:44 +09:00
anatawa12
d32631d159 fix: query error in notes/featured (#12439) 2023-11-26 12:54:23 +09:00
zyoshoka
2ee48ae04d fix(backend): ギャラリーの人気の投稿の選出にidを用いるように (#12448) 2023-11-26 10:05:56 +09:00
zyoshoka
7a494b2aa7 fix(backend): rename FunoutTimelineService to FanoutTimelineService (#12453) 2023-11-26 10:02:22 +09:00
zyoshoka
3e0231d995 fix(backend): 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正 (#12455)
* fix(backend): 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正

* Update CHANGELOG.md

* add test

* fix: incorrect bob's username
2023-11-26 10:01:06 +09:00
woxtu
c8b85a98b8 Add mocks for Web Audio API (#12457) 2023-11-26 09:54:24 +09:00
かっこかり
95095ee8d1 enhance(frontend): ユーザーのRawデータを読めるページを復活させる (#12436)
* (add) User raw page

* Update Changelog

* fix lint
2023-11-24 21:11:18 +09:00
syuilo
ccdb8ce7fc Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2023-11-24 20:25:36 +09:00
まっちゃとーにゅ
da3064343b enhance(frontend): 絵文字のオートコンプリートのアルゴリズムの改善 (MisskeyIO#261)
* 実際は同じ絵文字なら重複してサジェストに出ないように
* エイリアスではない絵文字>前方一致>部分一致>あいまい検索順で表示されるようになるように
2023-11-24 20:25:29 +09:00
yukineko
252efe8252 fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正 (#12431)
* fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正

* update: CHANGELOG.md
2023-11-24 20:19:46 +09:00
Camilla Ett
9c84055f50 Feat(frontend): コントロールパネル「通報」において、通報者もリンクで飛べるように修正 (#12427) 2023-11-24 16:19:37 +09:00
anatawa12
536f08c401 fix: hard mute limit not applied (#12428)
* fix: hard mute limit not applied

* Update CHANGELOG.md
2023-11-24 15:09:25 +09:00
woxtu
f7bdf5a2c0 Replace deprecated Repository.findOneById() (#12426) 2023-11-24 09:48:36 +09:00
syuilo
06ed64f26f update node to 20.10.0 2023-11-24 09:20:41 +09:00
syuilo
97c10ed1e5 Update index.d.ts 2023-11-24 09:20:34 +09:00
Camilla Ett
30b443de55 feat(frontend): リアクションの横幅を150pxに制限するかどうかユーザーが選べるように (#12416)
* feat(frontend): リアクションの横幅を150pxに制限するかどうかユーザーが選べるように

* localesの変更をjs-JP.ymlのみに修正し、日本語をより分かりやすく

* クラス名を.icon から .limitWidthに変更
2023-11-24 08:37:27 +09:00
mappi
521db37ca7 feat 12325 (#12425) 2023-11-24 08:36:55 +09:00
かっこかり
bf2d2ff0ca fix(frontend): プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正 (#12424)
* (fix) 招待コードを一度のみ利用できるように

* Update Changelog

* (fix) profile media grid

* Update Changelog

* Change Changelog
2023-11-23 21:18:24 +09:00
かっこかり
cba66c921e fix(frontend): コードエディタが正しく表示されない問題を修正 (#12418)
* (fix) コードエディタが正しく表示されない問題を修正

* Update Changelog
2023-11-23 20:37:41 +09:00
anatawa12
44a378c46e Use generate-api-json for api.json diff GitHub comment (#12408)
* ci: use generate-api-json to get api.json changes

* restore copying default.yml

* refactor: get api.json with single workflow

* ci: api.jsonのdiffをbackendが変更されたときのみ取るように
2023-11-23 20:17:21 +09:00
かっこかり
ed6f866a4f enhance/fix(AP/frontend): 最近追加されたMFMのもろもろを修正 (#12420)
* (enhance) MFM rubyが連合されるように

* Update Changelog

* Update Changelog

* (fix) unixtimeのフォールバック (AP)

* (fix) unixtimeのフォールバック (frontend)

* Update Changelog
2023-11-23 19:49:45 +09:00
かっこかり
4a2a44831b fix(backend): 招待コードが使い回せる問題を修正 (#12423)
* (fix) 招待コードを一度のみ利用できるように

* Update Changelog
2023-11-23 19:34:14 +09:00
anatawa12
864827f788 Hard mute (#12376)
* feat(backend,misskey-js): hard mute storage in backend

* fix(backend,misskey-js): mute word record type

* chore(frontend): generalize XWordMute

* feat(frontend): configure hard mute

* feat(frontend): hard mute notes on the timelines

* lint(backend,frontend): fix lint failure

* chore(misskey-js): update api.md

* fix(backend): test failure

* chore(frontend): check word mute for reply

* chore: limit hard mute count
2023-11-23 18:56:20 +09:00
おさむのひと
ded328fb43 絵文字のオートコンプリート強化の対応 (#12365)
* 前方一致・部分一致でなくても近似値でヒットするように

* fix CHANGELOG.md

* for of に変更

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-11-23 08:13:51 +09:00
syuilo
b15f293b82 fix lint, resolve code smell
Co-Authored-By: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
2023-11-22 18:46:27 +09:00
おさむのひと
c284d41b5b swagger-cli validateがvalidとなるapi.jsonを作れるようにする (#12403)
* api.jsonがswagger-cli validateでエラーにならないように生成ロジックを修正

* フィールドの消し方に不備があったので変更

* バックエンドを起動しなくてもapi.jsonを作れるようにした

* deepCopyしてからレスポンス部分を作るようにした

* fix CHANGELOG.md

* securitySchemesの定義を復活&ApiCallServiceの実装的にベアラトークンなのでその形で

* bodyが無い(空オブジェクト)のときはrequestBodyを描画しないようにする

* allowGetがtrueな項目はget用の記載も作成

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-11-22 17:08:56 +09:00
taichan
a4f8863786 taichanne30 -> tai-cha (#12405) 2023-11-22 12:20:07 +09:00
y.takahashi
c6ed06d783 twitter埋め込みのsandbox属性にallow-popups-to-escape-sandboxを追加 (#12400)
Co-authored-by: unarist <m.unarist@gmail.com>
2023-11-22 10:19:30 +09:00
皐月なふ (Nafu Satsuki)
18bdec9641 fix: verifymail.io APIの設定項目が反映されない (#12399) 2023-11-22 10:13:46 +09:00
おさむのひと
4b13179ff9 サウンド再生方法の変更に追従できていなかった所を修正 (#12368)
Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-11-21 20:05:04 +09:00
nenohi
481bca4cf2 広告掲載ページにてfilterをわかりやすく (#12385) 2023-11-21 19:50:06 +09:00
おさむのひと
b3d1cc9525 サーバ起動時にアンテナが非アクティブだった場合、アクティブ化しても再起動するまで反映されない (#12391)
* サーバ起動時にアンテナが非アクティブだった場合、アクティブ化しても再起動するまで反映されない

* Fix CHANGELOG.md

* lastUsedAtの更新に不備が出るので修正

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-11-21 15:32:34 +09:00
おさむのひと
b5be0e5780 note.tsのchannelを正しい形にしたことにより表出化した型チェックエラーを修正 (#12395)
Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-11-21 15:12:05 +09:00
syuilo
77ac51a680 update typescript to 5.3 2023-11-21 11:32:13 +09:00
おさむのひと
8bd9077f77 json-schema配下の最新化 (#12312)
* user.ts、page.ts、drive-folder.tsを各EntityServiceの戻り値をもとに最新化

* 再確認

* fix error

* note以外の残りのファイルを対応

* fix CHANGELOG.md

* fix CHANGELOG.md

* fix user.ts

* fix user.ts

* コメント対応

* fix note.ts

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-11-21 11:13:56 +09:00
anatawa12
2ec3227012 update api.md (#12379)
for API changes in b65fd34981
2023-11-21 10:48:01 +09:00
syuilo
cd2131c4b5 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2023-11-21 09:55:52 +09:00
syuilo
ed0cc443ea fix(backend): ロールタイムラインが保存されない問題を修正 2023-11-21 09:55:49 +09:00
果物リン
e0de86359c backendのプロジェクトで単体で start できないのを修正 (#12371) 2023-11-19 13:39:25 +09:00
yukineko
02b0adf31f fix: 「設定のバックアップ」に一部の設定項目が含まれていない問題を修正 (#12366)
* fix: 一部の設定項目がバックアップに含まれていなかったのを修正

* update: CHANGELOG.md

* remove: バックアップ不要な項目を削除
2023-11-19 11:45:24 +09:00
Lynx Kotoura
cbebe85ccf ページ一覧ページの表示がモバイル環境において崩れているのを修正 (#12354)
* fix style of list of pages on mobile

* overflow clip に変えた
2023-11-19 11:43:04 +09:00
syuilo
b65fd34981 tweak of 2b6f789a5b 2023-11-19 10:18:57 +09:00
syuilo
2f7d10bf23 Update CHANGELOG.md 2023-11-18 21:08:32 +09:00
Nafu Satsuki
2b6f789a5b feat(moderation): モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (MisskeyIO#222)
Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
2023-11-18 21:07:47 +09:00
syuilo
30dc6e691d lint fix 2023-11-18 21:04:00 +09:00
syuilo
af668b15c4 Update CHANGELOG.md 2023-11-18 21:03:01 +09:00
Nafu Satsuki
0a73973a7c メールアドレスの認証にverifymail.ioを使えるようにする。 2023-11-18 21:01:53 +09:00
おさむのひと
83ea0395f6 DeepL TranslationのPro accountトグルスイッチが表示されていなかったのを修正 (#12355)
Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-11-17 22:26:54 +09:00
syuilo
f007890e84 Revert "chore(frontend): tweak rt style for safari"
This reverts commit 76ccae8a2f.
2023-11-17 18:31:09 +09:00
syuilo
76ccae8a2f chore(frontend): tweak rt style for safari 2023-11-17 18:17:32 +09:00
syuilo
04709cf256 2023.11.1 2023-11-17 18:05:12 +09:00
syuilo
850b834758 New translations ja-jp.yml (Chinese Traditional) (#12351) 2023-11-17 18:04:42 +09:00
syuilo
08b3662bb8 chore(frontend): tweak ui 2023-11-17 18:00:42 +09:00
syuilo
4a7ccf6deb tweak MkTime.vue 2023-11-17 17:54:13 +09:00
syuilo
4b3f9bd9a6 enhance(backend): MFMのunixtimeをISO形式で連合するように 2023-11-17 15:44:36 +09:00
syuilo
5f5712a3ee fix(frontend): MFM unixtimeのプレビューがリアルタイムで反映されない
Fix #12350
2023-11-17 15:33:57 +09:00
syuilo
1518c5ddb0 2023.11.1-beta.2 2023-11-17 15:10:14 +09:00
syuilo
4f9922d46c update deps 2023-11-17 15:10:04 +09:00
syuilo
a9a743dab9 enhance(frontend): MFMでUNIX時間を指定して日時を表示できるように
Resolve #12294
2023-11-17 15:05:37 +09:00
syuilo
4d1a2bad17 typo 2023-11-17 13:27:33 +09:00
syuilo
cbab3affc9 Update packages/frontend/src/pages/role.vue
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
2023-11-17 13:26:55 +09:00
syuilo
f89a827aa9 Update packages/frontend/src/pages/role.vue
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
2023-11-17 13:26:48 +09:00
syuilo
43cb2d478c enhance(frontend): ruby内でMFMを使えるように 2023-11-17 13:20:40 +09:00
syuilo
b517d76084 enhance(frontend): MFMでルビを振れるように
Resolve #9161
2023-11-17 13:09:56 +09:00
syuilo
5ab7e03804 New Crowdin updates (#12348)
* New translations ja-jp.yml (Russian)

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

* New translations ja-jp.yml (French)

* New translations ja-jp.yml (French)
2023-11-17 09:20:21 +09:00
syuilo
89389ad744 Update CHANGELOG.md 2023-11-16 16:03:17 +09:00
おさむのひと
1eb769dbe8 LTLに特定条件下でチャンネル投稿が混ざり込む現象の修正 (#12347)
* LTLにチャンネル投稿を含まないように修正

* fix CHANGELOG.md

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-11-16 16:02:46 +09:00
syuilo
9d78a1a8b3 enhance(backend): make ftt db fallback configurable 2023-11-16 10:20:57 +09:00
syuilo
838c70192e Update CHANGELOG.md 2023-11-15 18:04:26 +09:00
おさむのひと
38d6580a36 通知音などの発音方法を変え、iOSで音声出力が競合しないようにする (#12339)
* HTMLAudioElementを使わないようにする

* fix CHANGELOG.md

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
2023-11-15 18:03:15 +09:00
syuilo
ca81f0ddbb fix(backend): 追加情報のカスタム絵文字がユーザー情報のtagに含まれない問題を修正
Fix #12316
2023-11-15 16:17:21 +09:00
syuilo
be6778ac61 chore(backend): improve performance 2023-11-15 16:10:05 +09:00
syuilo
7d24b29eb8 Update CHANGELOG.md 2023-11-15 15:51:01 +09:00
129 changed files with 3804 additions and 2024 deletions

View File

@@ -8,7 +8,7 @@
"version": "8.9.2"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20.5.1"
"version": "20.10.0"
}
},
"forwardPorts": [3000],

View File

@@ -6,37 +6,33 @@ on:
branches:
- master
- develop
paths:
- packages/backend/**
- .github/workflows/get-api-diff.yml
jobs:
get-base:
get-from-misskey:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node-version: [20.5.1]
services:
db:
image: postgres:13
ports:
- 5432:5432
env:
POSTGRES_DB: misskey
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_USER: example-misskey-user
POSTGRESS_PASS: example-misskey-pass
redis:
image: redis:7
ports:
- 6379:6379
node-version: [20.10.0]
api-json-name: [api-base.json, api-head.json]
include:
- api-json-name: api-base.json
repo-name: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.base_ref }}
- api-json-name: api-head.json
repo-name: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.head_ref }}
steps:
- uses: actions/checkout@v4.1.1
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.base_ref }}
repository: ${{ matrix.repo-name }}
ref: ${{ matrix.ref }}
submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v2
@@ -56,121 +52,15 @@ jobs:
run: cp .config/example.yml .config/default.yml
- name: Build
run: pnpm build
- name : Migrate
run: pnpm migrate
- name: Launch misskey
run: |
screen -S misskey -dm pnpm run dev
sleep 30s
- name: Wait for Misskey to be ready
run: |
MAX_RETRIES=12
RETRY_DELAY=5
count=0
until $(curl --output /dev/null --silent --head --fail http://localhost:3000) || [[ $count -eq $MAX_RETRIES ]]; do
printf '.'
sleep $RETRY_DELAY
count=$((count + 1))
done
if [[ $count -eq $MAX_RETRIES ]]; then
echo "Failed to connect to Misskey after $MAX_RETRIES attempts."
exit 1
fi
- id: fetch
name: Get api.json from Misskey
run: |
RESULT=$(curl --retry 5 --retry-delay 5 --retry-max-time 60 http://localhost:3000/api.json)
echo $RESULT > api-base.json
- name: Generate API JSON
run: pnpm --filter backend generate-api-json
- name: Copy API.json
run: cp packages/backend/built/api.json ${{ matrix.api-json-name }}
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: api-artifact
path: api-base.json
- name: Kill Misskey Job
run: screen -S misskey -X quit
get-head:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node-version: [20.5.1]
services:
db:
image: postgres:13
ports:
- 5432:5432
env:
POSTGRES_DB: misskey
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_USER: example-misskey-user
POSTGRESS_PASS: example-misskey-pass
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4.1.1
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.head_ref }}
submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml
- name: Copy Configure
run: cp .config/example.yml .config/default.yml
- name: Build
run: pnpm build
- name : Migrate
run: pnpm migrate
- name: Launch misskey
run: |
screen -S misskey -dm pnpm run dev
sleep 30s
- name: Wait for Misskey to be ready
run: |
MAX_RETRIES=12
RETRY_DELAY=5
count=0
until $(curl --output /dev/null --silent --head --fail http://localhost:3000) || [[ $count -eq $MAX_RETRIES ]]; do
printf '.'
sleep $RETRY_DELAY
count=$((count + 1))
done
if [[ $count -eq $MAX_RETRIES ]]; then
echo "Failed to connect to Misskey after $MAX_RETRIES attempts."
exit 1
fi
- id: fetch
name: Get api.json from Misskey
run: |
RESULT=$(curl --retry 5 --retry-delay 5 --retry-max-time 60 http://localhost:3000/api.json)
echo $RESULT > api-head.json
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: api-artifact
path: api-head.json
- name: Kill Misskey Job
run: screen -S misskey -X quit
path: ${{ matrix.api-json-name }}
save-pr-number:
runs-on: ubuntu-latest

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
node-version: [20.5.1]
node-version: [20.10.0]
services:
postgres:

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
node-version: [20.5.1]
node-version: [20.10.0]
steps:
- uses: actions/checkout@v4.1.1
@@ -51,7 +51,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [20.5.1]
node-version: [20.10.0]
browser: [chrome]
services:

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
node-version: [20.5.1]
node-version: [20.10.0]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
node-version: [20.5.1]
node-version: [20.10.0]
steps:
- uses: actions/checkout@v4.1.1

View File

@@ -1 +1 @@
20.5.1
20.10.0

View File

@@ -5,7 +5,7 @@
-
### Client
-
- Fix: ページ一覧ページの表示がモバイル環境において崩れているのを修正
### Server
-
@@ -15,25 +15,63 @@
## 2023.x.x (unreleased)
### General
- Feat: コントロールパネルの「照会」から、入力されたメールアドレスを持つユーザーを検索できるようになりました
- Enhance: ローカリゼーションの更新
- Enhance: 依存関係の更新
- Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed)
- Feat: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83)
- Feat: TL上からートが見えなくなるワードミュートであるハードミュートを追加
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
### Client
- Enhance: 絵文字のオートコンプリート機能強化 #12364
- Enhance: ユーザーのRawデータを表示するページが復活
- Enhance: リアクション選択時に音を鳴らせるように
- Enhance: サウンドにドライブのファイルを使用できるように
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367
- Fix: コードエディタが正しく表示されない問題を修正
- Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正
- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正
### Server
- Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように
- Fix: 時間経過により無効化されたアンテナを再有効化したとき、サーバ再起動までその状況が反映されないのを修正 #12303
- Fix: ロールタイムラインが保存されない問題を修正
- Fix: api.jsonの生成ロジックを改善 #12402
- Fix: 招待コードが使い回せる問題を修正
- Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正
- Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正
## 2023.11.1
### General
- Feat: 管理者がコントロールパネルからメールアドレスの照会を行えるようになりました
- Enhance: ローカリゼーションの更新
- Enhance: 依存関係の更新
- Enhance: json-schema(OpenAPIの戻り値として使用されるスキーマ定義)を出来る限り最新化 #12311
### Client
- Enhance: MFMでルビを振れるように
- 例: `$[ruby 三須木 みすき]`
- Enhance: MFMでUNIX時間を指定して日時を表示できるように
- 例: `$[unixtime 1701356400]`
- Enhance: プラグインでエラーが発生した場合のハンドリングを強化
- Enhance: 細かなUIのブラッシュアップ
- Enhance: サウンド設定に「サウンドを出力しない」と「Misskeyがアクティブな時のみサウンドを出力する」を追加
- Fix: 効果音が再生されるとデバイスで再生している動画や音声が停止する問題を修正 #12339
- Fix: デッキに表示されたチャンネルの表示先チャンネルを切り替えた際、即座に反映されない問題を修正 #12236
- Fix: プラグインでノートの表示を書き換えられない問題を修正
- Fix: アイコンデコレーションが見切れる場合がある問題を修正
- Fix: 「フォロー中の人全員の返信を含める/含めないようにする」のボタンを押下した際の確認が機能していない問題を修正
- Fix: 非ログイン時に「ノートを追加」を表示しないように変更 #12309
- Fix: 非ログイン時に「メモを追加」を表示しないように変更 #12309
- Fix: 絵文字ピッカーでの検索が更新されない問題を修正
- Fix: 特定の条件下でートがnyaizeされない問題を修正
### Server
- Enhance: FTTのデータベースへのフォールバック処理を行うかどうかを設定可能に
- Fix: トークンのないプラグインをアンインストールするときにエラーが出ないように
- Fix: 投稿通知がオンでもダイレクト投稿はユーザーに通知されないようにされました
- Fix: ユーザタイムラインの「ノート」選択時にリノートが混ざり込んでしまうことがある問題の修正 #12306
- Fix: LTLに特定条件下にてチャンネルへの投稿が混ざり込む現象を修正
- Fix: ActivityPub: 追加情報のカスタム絵文字がユーザー情報のtagに含まれない問題を修正
- Fix: ActivityPubに関するセキュリティの向上
- Fix: 非公開の投稿に対して返信できないように

View File

@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.4
ARG NODE_VERSION=20.5.1-bullseye
ARG NODE_VERSION=20.10.0-bullseye
# build assets & compile TypeScript

View File

@@ -564,6 +564,10 @@ output: "Output"
script: "Script"
disablePagesScript: "Disable AiScript on Pages"
updateRemoteUser: "Update remote user information"
unsetUserAvatar: "Delete user icon"
unsetUserAvatarConfirm: "Are you sure that you want to delete this user's icon?"
unsetUserBanner: "Delete user banner"
unsetUserBannerConfirm: "Are you sure that you want to delete this user's banner?"
deleteAllFiles: "Delete all files"
deleteAllFilesConfirm: "Are you sure that you want to delete all files?"
removeAllFollowing: "Unfollow all followed users"

View File

@@ -764,7 +764,7 @@ inUse: "utilisé"
editCode: "Modifier le code"
apply: "Appliquer"
receiveAnnouncementFromInstance: "Recevoir les messages d'information de l'instance"
emailNotification: "Notifications par mail"
emailNotification: "Notifications par courriel"
publish: "Public"
inChannelSearch: "Chercher dans le canal"
useReactionPickerForContextMenu: "Clic-droit pour ouvrir le panneau de réactions"
@@ -998,6 +998,7 @@ license: "Licence"
myClips: "Mes clips"
retryAllQueuesConfirmText: "Cela peut augmenter temporairement la charge du serveur."
showClipButtonInNoteFooter: "Ajouter « Clip » au menu d'action de la note"
reactionsDisplaySize: "Taille de l'affichage des réactions"
noteIdOrUrl: "Identifiant de la note ou URL"
video: "Vidéo"
videos: "Vidéos"
@@ -1053,6 +1054,7 @@ pastAnnouncements: "Annonces passées"
replies: "Répondre"
renotes: "Renoter"
loadReplies: "Inclure les réponses"
loadConversation: "Afficher la conversation"
pinnedList: "Liste épinglée"
notifyNotes: "Notifier à propos des nouvelles notes"
authentication: "Authentification"
@@ -1144,7 +1146,7 @@ _initialTutorial:
direct: "Uniquement visible aux utilisateurs de votre choix. Les récipients seront notifiés. Cette option peut être utilisée comme alternative aux messages directs."
doNotSendConfidencialOnDirect1: "Faites attention quand vous envoyez vos informations sensibles !"
doNotSendConfidencialOnDirect2: "Les administrateurs de l'instance destinataire peuvent voir toutes les notes publiées. Soyez prudent·e avec vos informations sensibles quand vous envoyez des notes directes aux utilisateurs dont vous ne vous fiez pas aux instances."
localOnly: "Désactiver la fédération de la note à d'autres instances. Les utilisateurs d'autres instances ne pourront pas voir directement la note quelle que soit l'étendue de la publication mentionnée ci-dessus."
localOnly: "Désactiver la fédération de la note aux autres instances. Les utilisateurs des autres instances ne pourront pas voir directement la note quelle que soit l'étendue de la publication mentionnée ci-dessus."
_cw:
title: "Masquer le contenu (CW)"
description: "Au lieu du corps du texte, le contenu du champ « commentaires » s'affichera. Appuyez sur « afficher le contenu » pour voir le corps du texte."
@@ -1171,7 +1173,12 @@ _timelineDescription:
global: "Sur le fil global, vous pouvez voir les notes de toutes les instances connectées."
_serverSettings:
iconUrl: "URL de licône"
appIconResolutionMustBe: "La résolution doit être au moins {resolution}."
shortName: "Nom court"
shortNameDescription: "Si le nom officiel de l'instance est long, cette abréviation peut être affichée à la place."
fanoutTimelineDescription: "Si activée, la performance de la récupération de la chronologie augmentera considérablement et la charge sur la base de données sera réduite. En revanche, l'utilisation de la mémoire de Redis augmentera. Considérez désactiver cette option si le serveur est bas en mémoire ou instable."
fanoutTimelineDbFallback: "Recours à la base de données"
fanoutTimelineDbFallbackDescription: "Si activée, une demande supplémentaire à la base de données est effectuée comme solution de rechange quand le fil n'est pas mis en cache. Si désactivée, la demande à la base de données n'est pas effectuée, ce qui réduit davantage la charge du serveur mais limite l'étendue du fil récupérable."
_accountMigration:
moveFrom: "Migrer un autre compte vers le présent compte"
moveFromSub: "Créer un alias vers un autre compte"
@@ -1304,6 +1311,9 @@ _achievements:
flavor: "Attendez une minute, vous êtes sur le mauvais site web ?"
_brainDiver:
flavor: "Misskey-Misskey La-Tu-Ma"
_smashTestNotificationButton:
title: "Débordement de tests"
description: "Détruire le bouton de test de notifications dans un intervalle extrêmement court"
_tutorialCompleted:
title: "Diplôme de la course élémentaire de Misskey"
description: "Terminer le tutoriel"
@@ -1332,6 +1342,7 @@ _role:
canManageCustomEmojis: "Gestion des émojis personnalisés"
canManageAvatarDecorations: "Gestion des décorations d'avatar"
wordMuteMax: "Nombre maximal de caractères dans le filtre de mots"
canUseTranslator: "Usage de la fonctionnalité de traduction"
_sensitiveMediaDetection:
description: "L'apprentissage automatique peut être utilisé pour détecter automatiquement les médias sensibles à modérer. La sollicitation des serveurs augmente légèrement."
sensitivity: "Sensibilité de la détection"
@@ -1819,6 +1830,7 @@ _notification:
unreadAntennaNote: "Antenne {name}"
emptyPushNotificationMessage: "Les notifications push ont été mises à jour"
achievementEarned: "Accomplissement"
testNotification: "Tester la notification"
reactedBySomeUsers: "{n} utilisateur·rice·s ont réagi"
renotedBySomeUsers: "{n} utilisateur·rice·s ont renoté"
followedBySomeUsers: "{n} utilisateur·rice·s se sont abonné·e·s à vous"

32
locales/index.d.ts vendored
View File

@@ -547,6 +547,8 @@ export interface Locale {
"popout": string;
"volume": string;
"masterVolume": string;
"notUseSound": string;
"useSoundOnlyWhenActive": string;
"details": string;
"chooseEmoji": string;
"unableToProcess": string;
@@ -567,6 +569,10 @@ export interface Locale {
"script": string;
"disablePagesScript": string;
"updateRemoteUser": string;
"unsetUserAvatar": string;
"unsetUserAvatarConfirm": string;
"unsetUserBanner": string;
"unsetUserBannerConfirm": string;
"deleteAllFiles": string;
"deleteAllFilesConfirm": string;
"removeAllFollowing": string;
@@ -638,6 +644,7 @@ export interface Locale {
"smtpSecureInfo": string;
"testEmail": string;
"wordMute": string;
"hardWordMute": string;
"regexpError": string;
"regexpErrorDescription": string;
"instanceMute": string;
@@ -1035,6 +1042,7 @@ export interface Locale {
"enableChartsForFederatedInstances": string;
"showClipButtonInNoteFooter": string;
"reactionsDisplaySize": string;
"limitWidthOfReaction": string;
"noteIdOrUrl": string;
"video": string;
"videos": string;
@@ -1285,6 +1293,8 @@ export interface Locale {
"shortName": string;
"shortNameDescription": string;
"fanoutTimelineDescription": string;
"fanoutTimelineDbFallback": string;
"fanoutTimelineDbFallbackDescription": string;
};
"_accountMigration": {
"moveFrom": string;
@@ -1635,7 +1645,9 @@ export interface Locale {
"assignTarget": string;
"descriptionOfAssignTarget": string;
"manual": string;
"manualRoles": string;
"conditional": string;
"conditionalRoles": string;
"condition": string;
"isConditionalRole": string;
"isPublic": string;
@@ -1933,6 +1945,15 @@ export interface Locale {
"notification": string;
"antenna": string;
"channel": string;
"reaction": string;
};
"_soundSettings": {
"driveFile": string;
"driveFileWarn": string;
"driveFileTypeWarn": string;
"driveFileTypeWarnDescription": string;
"driveFileDurationWarn": string;
"driveFileDurationWarnDescription": string;
};
"_ago": {
"future": string;
@@ -1946,6 +1967,15 @@ export interface Locale {
"yearsAgo": string;
"invalid": string;
};
"_timeIn": {
"seconds": string;
"minutes": string;
"hours": string;
"days": string;
"weeks": string;
"months": string;
"years": string;
};
"_time": {
"second": string;
"minute": string;
@@ -2402,6 +2432,8 @@ export interface Locale {
"createAvatarDecoration": string;
"updateAvatarDecoration": string;
"deleteAvatarDecoration": string;
"unsetUserAvatar": string;
"unsetUserBanner": string;
};
"_fileViewer": {
"title": string;

View File

@@ -544,6 +544,8 @@ showInPage: "ページで表示"
popout: "ポップアウト"
volume: "音量"
masterVolume: "マスター音量"
notUseSound: "サウンドを出力しない"
useSoundOnlyWhenActive: "Misskeyがアクティブな時のみサウンドを出力する"
details: "詳細"
chooseEmoji: "絵文字を選択"
unableToProcess: "操作を完了できません"
@@ -564,6 +566,10 @@ output: "出力"
script: "スクリプト"
disablePagesScript: "Pagesのスクリプトを無効にする"
updateRemoteUser: "リモートユーザー情報の更新"
unsetUserAvatar: "アイコンを解除"
unsetUserAvatarConfirm: "アイコンを解除しますか?"
unsetUserBanner: "バナーを解除"
unsetUserBannerConfirm: "バナーを解除しますか?"
deleteAllFiles: "すべてのファイルを削除"
deleteAllFilesConfirm: "すべてのファイルを削除しますか?"
removeAllFollowing: "フォローを全解除"
@@ -635,6 +641,7 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する"
smtpSecureInfo: "STARTTLS使用時はオフにします。"
testEmail: "配信テスト"
wordMute: "ワードミュート"
hardWordMute: "ハードワードミュート"
regexpError: "正規表現エラー"
regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:"
instanceMute: "サーバーミュート"
@@ -1032,6 +1039,7 @@ enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
reactionsDisplaySize: "リアクションの表示サイズ"
limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する"
noteIdOrUrl: "ートIDまたはURL"
video: "動画"
videos: "動画"
@@ -1272,6 +1280,8 @@ _serverSettings:
shortName: "略称"
shortNameDescription: "サーバーの正式名称が長い場合に、代わりに表示することのできる略称や通称。"
fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。"
fanoutTimelineDbFallback: "データベースへのフォールバック"
fanoutTimelineDbFallbackDescription: "有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。"
_accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行"
@@ -1545,7 +1555,9 @@ _role:
assignTarget: "アサイン"
descriptionOfAssignTarget: "<b>マニュアル</b>は誰がこのロールに含まれるかを手動で管理します。\n<b>コンディショナル</b>は条件を設定し、それに合致するユーザーが自動で含まれるようになります。"
manual: "マニュアル"
manualRoles: "マニュアルロール"
conditional: "コンディショナル"
conditionalRoles: "コンディショナルロール"
condition: "条件"
isConditionalRole: "これはコンディショナルロールです。"
isPublic: "公開ロール"
@@ -1838,6 +1850,15 @@ _sfx:
notification: "通知"
antenna: "アンテナ受信"
channel: "チャンネル通知"
reaction: "リアクション選択時"
_soundSettings:
driveFile: "ドライブの音声を使用"
driveFileWarn: "ドライブのファイルを選択してください"
driveFileTypeWarn: "このファイルは対応していません"
driveFileTypeWarnDescription: "音声ファイルを選択してください"
driveFileDurationWarn: "音声が長すぎます"
driveFileDurationWarnDescription: "長い音声を使用するとMisskeyの使用に支障をきたす可能性があります。それでも続行しますか"
_ago:
future: "未来"
@@ -1849,7 +1870,16 @@ _ago:
weeksAgo: "{n}週間前"
monthsAgo: "{n}ヶ月前"
yearsAgo: "{n}年前"
invalid: "ありません"
invalid: "日時の解析に失敗"
_timeIn:
seconds: "{n}秒後"
minutes: "{n}分後"
hours: "{n}時間後"
days: "{n}日後"
weeks: "{n}週間後"
months: "{n}ヶ月後"
years: "{n}年後"
_time:
second: "秒"
@@ -2303,6 +2333,8 @@ _moderationLogTypes:
createAvatarDecoration: "アイコンデコレーションを作成"
updateAvatarDecoration: "アイコンデコレーションを更新"
deleteAvatarDecoration: "アイコンデコレーションを削除"
unsetUserAvatar: "ユーザーのアイコンを解除"
unsetUserBanner: "ユーザーのバナーを解除"
_fileViewer:
title: "ファイルの詳細"

View File

@@ -59,7 +59,7 @@ copyFileId: "Скопировать ID файла"
copyFolderId: "Скопировать ID папки"
copyProfileUrl: "Скопировать URL профиля "
searchUser: "Поиск людей"
reply: "Ответить"
reply: "Ответ"
loadMore: "Показать еще"
showMore: "Показать еще"
showLess: "Закрыть"
@@ -1069,7 +1069,7 @@ unused: "Неиспользуемый"
expired: "Срок действия приглашения истёк"
doYouAgree: "Согласны?"
icon: "Аватар"
replies: "Ответить"
replies: "Ответы"
renotes: "Репост"
flip: "Переворот"
_initialAccountSetting:
@@ -1899,7 +1899,7 @@ _notification:
app: "Уведомления из приложений"
_actions:
followBack: "отвечает взаимной подпиской"
reply: "Ответить"
reply: "Ответ"
renote: "Репост"
_deck:
alwaysShowMainColumn: "Всегда показывать главную колонку"

View File

@@ -299,7 +299,7 @@ light: "淺色"
dark: "深色"
lightThemes: "淺色主題"
darkThemes: "深色主題"
syncDeviceDarkMode: "同步至此裝置的深色模式設定"
syncDeviceDarkMode: "與設備的深色模式同步"
drive: "雲端硬碟"
fileName: "檔案名稱"
selectFile: "選擇檔案"
@@ -1266,6 +1266,8 @@ _serverSettings:
shortName: "簡稱"
shortNameDescription: "如果伺服器的正式名稱很長,可用簡稱或通稱代替。"
fanoutTimelineDescription: "如果啟用的話檢索各個時間軸的性能會顯著提昇資料庫的負荷也會減少。不過Redis 的記憶體使用量會增加。如果伺服器的記憶體容量比較少或者運行不穩定,可以停用。"
fanoutTimelineDbFallback: "資料庫的回退"
fanoutTimelineDbFallbackDescription: "若啟用,在時間軸沒有快取的情況下將執行回退處理以額外查詢資料庫。若停用,可以透過不執行回退處理來進一步減少伺服器的負荷,但會限制可取得的時間軸範圍。"
_accountMigration:
moveFrom: "從其他帳戶遷移到這個帳戶"
moveFromSub: "為另一個帳戶建立別名"
@@ -1817,6 +1819,14 @@ _ago:
monthsAgo: "{n} 個月前"
yearsAgo: "{n} 年前"
invalid: "無"
_timeIn:
seconds: "{n} 秒後"
minutes: "{n} 分後"
hours: "{n} 小時後"
days: "{n} 日後"
weeks: "{n} 週後"
months: "{n} 個月後"
years: "{n} 年後"
_time:
second: "秒"
minute: "分鐘"

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2023.11.1-beta.1",
"version": "2023.12.0-beta.1",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -49,15 +49,15 @@
"js-yaml": "4.1.0",
"postcss": "8.4.31",
"terser": "5.24.0",
"typescript": "5.2.2"
"typescript": "5.3.2"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "6.11.0",
"@typescript-eslint/parser": "6.11.0",
"@typescript-eslint/eslint-plugin": "6.12.0",
"@typescript-eslint/parser": "6.12.0",
"cross-env": "7.0.3",
"cypress": "13.5.0",
"eslint": "8.53.0",
"start-server-and-test": "2.0.2"
"cypress": "13.6.0",
"eslint": "8.54.0",
"start-server-and-test": "2.0.3"
},
"optionalDependencies": {
"@tensorflow/tfjs-core": "4.4.0"

View File

@@ -0,0 +1,8 @@
import { loadConfig } from './built/config.js'
import { genOpenapiSpec } from './built/server/api/openapi/gen-spec.js'
import { writeFileSync } from "node:fs";
const config = loadConfig();
const spec = genOpenapiSpec(config);
writeFileSync('./built/api.json', JSON.stringify(spec), 'utf-8');

View File

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

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SupportVerifyMailApi1700303245007 {
name = 'SupportVerifyMailApi1700303245007'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "verifymailAuthKey" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "enableVerifymailApi" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableVerifymailApi"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "verifymailAuthKey"`);
}
}

View File

@@ -0,0 +1,11 @@
export class HardMute1700383825690 {
name = 'HardMute1700383825690'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "hardMutedWords" jsonb NOT NULL DEFAULT '[]'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "hardMutedWords"`);
}
}

View File

@@ -7,8 +7,8 @@
"node": ">=18.16.0"
},
"scripts": {
"start": "node ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js",
"start": "node ./built/boot/entry.js",
"start:test": "NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
"check:connect": "node ./check_connect.js",
@@ -23,7 +23,8 @@
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"test": "pnpm jest",
"test-and-coverage": "pnpm jest-and-coverage"
"test-and-coverage": "pnpm jest-and-coverage",
"generate-api-json": "node ./generate_api_json.js"
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
@@ -59,27 +60,27 @@
"dependencies": {
"@aws-sdk/client-s3": "3.412.0",
"@aws-sdk/lib-storage": "3.412.0",
"@bull-board/api": "5.9.1",
"@bull-board/fastify": "5.9.1",
"@bull-board/ui": "5.9.1",
"@bull-board/api": "5.9.2",
"@bull-board/fastify": "5.9.2",
"@bull-board/ui": "5.9.2",
"@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.2.0",
"@fastify/cookie": "9.1.0",
"@fastify/cookie": "9.2.0",
"@fastify/cors": "8.4.1",
"@fastify/express": "2.3.0",
"@fastify/http-proxy": "9.3.0",
"@fastify/multipart": "8.0.0",
"@fastify/static": "6.12.0",
"@fastify/view": "8.2.0",
"@nestjs/common": "10.2.8",
"@nestjs/core": "10.2.8",
"@nestjs/testing": "10.2.8",
"@nestjs/common": "10.2.10",
"@nestjs/core": "10.2.10",
"@nestjs/testing": "10.2.10",
"@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "8.3.5",
"@sinonjs/fake-timers": "11.2.2",
"@smithy/node-http-handler": "2.1.5",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.96",
"@smithy/node-http-handler": "2.1.10",
"@swc/cli": "0.1.63",
"@swc/core": "1.3.99",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "6.0.1",
@@ -87,7 +88,7 @@
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"body-parser": "1.20.2",
"bullmq": "4.13.2",
"bullmq": "4.14.2",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.1",
"chalk": "5.3.0",
@@ -99,7 +100,7 @@
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"fastify": "4.24.3",
"fastify-raw-body": "^4.2.2",
"fastify-raw-body": "4.3.0",
"feed": "4.2.2",
"file-type": "18.7.0",
"fluent-ffmpeg": "2.1.2",
@@ -113,11 +114,11 @@
"ipaddr.js": "2.1.0",
"is-svg": "5.0.0",
"js-yaml": "4.1.0",
"jsdom": "22.1.0",
"jsdom": "23.0.0",
"json5": "2.2.3",
"jsonld": "8.3.1",
"jsrsasign": "10.8.6",
"meilisearch": "0.35.0",
"jsrsasign": "10.9.0",
"meilisearch": "0.36.0",
"mfm-js": "0.23.3",
"microformats-parser": "1.5.2",
"mime-types": "2.1.35",
@@ -132,7 +133,7 @@
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
"otpauth": "9.1.5",
"otpauth": "9.2.0",
"parse5": "7.1.2",
"pg": "8.11.3",
"pkce-challenge": "4.0.1",
@@ -144,28 +145,28 @@
"qrcode": "1.5.3",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.20.5",
"re2": "1.20.9",
"redis-lock": "0.1.4",
"reflect-metadata": "0.1.13",
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.1",
"sanitize-html": "2.11.0",
"secure-json-parse": "^2.4.0",
"secure-json-parse": "2.7.0",
"sharp": "0.32.6",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
"systeminformation": "5.21.17",
"systeminformation": "5.21.18",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.17",
"typescript": "5.2.2",
"typescript": "5.3.2",
"ulid": "2.3.0",
"vary": "1.1.2",
"web-push": "3.6.6",
@@ -177,7 +178,7 @@
"@simplewebauthn/typescript-types": "8.3.4",
"@swc/jest": "0.2.29",
"@types/accepts": "1.3.7",
"@types/archiver": "6.0.1",
"@types/archiver": "6.0.2",
"@types/bcryptjs": "2.4.6",
"@types/body-parser": "1.19.5",
"@types/cbor": "6.0.0",
@@ -185,28 +186,28 @@
"@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.24",
"@types/http-link-header": "1.0.5",
"@types/jest": "29.5.8",
"@types/jest": "29.5.10",
"@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.5",
"@types/jsonld": "1.5.12",
"@types/jsdom": "21.1.6",
"@types/jsonld": "1.5.13",
"@types/jsrsasign": "10.5.12",
"@types/mime-types": "2.1.4",
"@types/ms": "0.7.34",
"@types/node": "20.9.0",
"@types/node": "20.10.0",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.14",
"@types/oauth": "0.9.4",
"@types/oauth2orize": "1.11.3",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.10.9",
"@types/pug": "2.0.9",
"@types/punycode": "2.1.2",
"@types/pug": "2.0.10",
"@types/punycode": "2.1.3",
"@types/qrcode": "1.5.5",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7",
"@types/sanitize-html": "2.9.4",
"@types/semver": "7.5.5",
"@types/sanitize-html": "2.9.5",
"@types/semver": "7.5.6",
"@types/sharp": "0.32.0",
"@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "8.1.5",
@@ -214,12 +215,12 @@
"@types/tmp": "0.2.6",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.3",
"@types/ws": "8.5.9",
"@typescript-eslint/eslint-plugin": "6.11.0",
"@typescript-eslint/parser": "6.11.0",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.12.0",
"@typescript-eslint/parser": "6.12.0",
"aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3",
"eslint": "8.53.0",
"eslint": "8.54.0",
"eslint-plugin-import": "2.29.0",
"execa": "8.0.1",
"jest": "29.7.0",

View File

@@ -16,7 +16,7 @@ import type { AntennasRepository, UserListMembershipsRepository } from '@/models
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
@@ -39,7 +39,7 @@ export class AntennaService implements OnApplicationShutdown {
private utilityService: UtilityService,
private globalEventService: GlobalEventService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
) {
this.antennasFetched = false;
this.antennas = [];
@@ -60,11 +60,21 @@ export class AntennaService implements OnApplicationShutdown {
lastUsedAt: new Date(body.lastUsedAt),
});
break;
case 'antennaUpdated':
this.antennas[this.antennas.findIndex(a => a.id === body.id)] = {
...body,
lastUsedAt: new Date(body.lastUsedAt),
};
case 'antennaUpdated': {
const idx = this.antennas.findIndex(a => a.id === body.id);
if (idx >= 0) {
this.antennas[idx] = {
...body,
lastUsedAt: new Date(body.lastUsedAt),
};
} else {
// サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり
this.antennas.push({
...body,
lastUsedAt: new Date(body.lastUsedAt),
});
}
}
break;
case 'antennaDeleted':
this.antennas = this.antennas.filter(a => a.id !== body.id);
@@ -84,7 +94,7 @@ export class AntennaService implements OnApplicationShutdown {
const redisPipeline = this.redisForTimelines.pipeline();
for (const antenna of matchedAntennas) {
this.funoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline);
this.fanoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline);
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
}

View File

@@ -62,7 +62,7 @@ import { FileInfoService } from './FileInfoService.js';
import { SearchService } from './SearchService.js';
import { ClipService } from './ClipService.js';
import { FeaturedService } from './FeaturedService.js';
import { FunoutTimelineService } from './FunoutTimelineService.js';
import { FanoutTimelineService } from './FanoutTimelineService.js';
import { ChannelFollowingService } from './ChannelFollowingService.js';
import { RegistryApiService } from './RegistryApiService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js';
@@ -194,7 +194,7 @@ const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: Fi
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
const $FunoutTimelineService: Provider = { provide: 'FunoutTimelineService', useExisting: FunoutTimelineService };
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
@@ -330,7 +330,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
SearchService,
ClipService,
FeaturedService,
FunoutTimelineService,
FanoutTimelineService,
ChannelFollowingService,
RegistryApiService,
ChartLoggerService,
@@ -459,7 +459,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$SearchService,
$ClipService,
$FeaturedService,
$FunoutTimelineService,
$FanoutTimelineService,
$ChannelFollowingService,
$RegistryApiService,
$ChartLoggerService,
@@ -589,7 +589,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
SearchService,
ClipService,
FeaturedService,
FunoutTimelineService,
FanoutTimelineService,
ChannelFollowingService,
RegistryApiService,
FederationChart,
@@ -717,7 +717,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$SearchService,
$ClipService,
$FeaturedService,
$FunoutTimelineService,
$FanoutTimelineService,
$ChannelFollowingService,
$RegistryApiService,
$FederationChart,

View File

@@ -3,9 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { URLSearchParams } from 'node:url';
import * as nodemailer from 'nodemailer';
import { Inject, Injectable } from '@nestjs/common';
import { validate as validateEmail } from 'deep-email-validator';
import { SubOutputFormat } from 'deep-email-validator/dist/output/output.js';
import { MetaService } from '@/core/MetaService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -13,6 +15,7 @@ import type Logger from '@/logger.js';
import type { UserProfilesRepository } from '@/models/_.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@Injectable()
export class EmailService {
@@ -27,6 +30,7 @@ export class EmailService {
private metaService: MetaService,
private loggerService: LoggerService,
private httpRequestService: HttpRequestService,
) {
this.logger = this.loggerService.getLogger('email');
}
@@ -160,14 +164,25 @@ export class EmailService {
email: emailAddress,
});
const validated = meta.enableActiveEmailValidation ? await validateEmail({
email: emailAddress,
validateRegex: true,
validateMx: true,
validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので
validateDisposable: true, // 捨てアドかどうかチェック
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
}) : { valid: true, reason: null };
const verifymailApi = meta.enableVerifymailApi && meta.verifymailAuthKey != null;
let validated;
if (meta.enableActiveEmailValidation && meta.verifymailAuthKey) {
if (verifymailApi) {
validated = await this.verifyMail(emailAddress, meta.verifymailAuthKey);
} else {
validated = meta.enableActiveEmailValidation ? await validateEmail({
email: emailAddress,
validateRegex: true,
validateMx: true,
validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので
validateDisposable: true, // 捨てアドかどうかチェック
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
}) : { valid: true, reason: null };
}
} else {
validated = { valid: true, reason: null };
}
const available = exist === 0 && validated.valid;
@@ -182,4 +197,65 @@ export class EmailService {
null,
};
}
private async verifyMail(emailAddress: string, verifymailAuthKey: string): Promise<{
valid: boolean;
reason: 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | null;
}> {
const endpoint = 'https://verifymail.io/api/' + emailAddress + '?key=' + verifymailAuthKey;
const res = await this.httpRequestService.send(endpoint, {
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, */*',
},
});
const json = (await res.json()) as {
block: boolean;
catch_all: boolean;
deliverable_email: boolean;
disposable: boolean;
domain: string;
email_address: string;
email_provider: string;
mx: boolean;
mx_fallback: boolean;
mx_host: string[];
mx_ip: string[];
mx_priority: { [key: string]: number };
privacy: boolean;
related_domains: string[];
};
if (json.email_address === undefined) {
return {
valid: false,
reason: 'format',
};
}
if (json.deliverable_email !== undefined && !json.deliverable_email) {
return {
valid: false,
reason: 'smtp',
};
}
if (json.disposable) {
return {
valid: false,
reason: 'disposable',
};
}
if (json.mx !== undefined && !json.mx) {
return {
valid: false,
reason: 'mx',
};
}
return {
valid: true,
reason: null,
};
}
}

View File

@@ -10,7 +10,7 @@ import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
@Injectable()
export class FunoutTimelineService {
export class FanoutTimelineService {
constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,

View File

@@ -5,11 +5,12 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { MiNote, MiUser } from '@/models/_.js';
import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
@@ -79,6 +80,11 @@ export class FeaturedService {
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
}
@bindThis
public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise<void> {
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score);
}
@bindThis
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
@@ -99,6 +105,11 @@ export class FeaturedService {
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, threshold);
}
@bindThis
public getGalleryPostsRanking(threshold: number): Promise<MiGalleryPost['id'][]> {
return this.getRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, threshold);
}
@bindThis
public getInChannelNotesRanking(channelId: MiNote['channelId'], threshold: number): Promise<MiNote['id'][]> {
return this.getRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, threshold);

View File

@@ -250,6 +250,12 @@ export class MfmService {
}
}
function fnDefault(node: mfm.MfmFn) {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
}
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
bold: (node) => {
const el = doc.createElement('b');
@@ -276,9 +282,69 @@ export class MfmService {
},
fn: (node) => {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
switch (node.props.name) {
case 'unixtime': {
const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
try {
const date = new Date(parseInt(text, 10) * 1000);
const el = doc.createElement('time');
el.setAttribute('datetime', date.toISOString());
el.textContent = date.toISOString();
return el;
} catch (err) {
return fnDefault(node);
}
}
case 'ruby': {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備りゅうび」となるようにする
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
} else {
const rt = node.children.at(-1);
if (!rt) {
return fnDefault(node);
}
const text = rt.type === 'text' ? rt.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備りゅうび」となるようにする
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
rtEl.appendChild(doc.createTextNode(text.trim()));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
}
}
default: {
return fnDefault(node);
}
}
},
blockCode: (node) => {

View File

@@ -54,7 +54,7 @@ import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
@@ -194,7 +194,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private idService: IdService,
private globalEventService: GlobalEventService,
private queueService: QueueService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
private noteReadService: NoteReadService,
private notificationService: NotificationService,
private relayService: RelayService,
@@ -843,9 +843,9 @@ export class NoteCreateService implements OnApplicationShutdown {
const r = this.redisForTimelines.pipeline();
if (note.channelId) {
this.funoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
this.funoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
const channelFollowings = await this.channelFollowingsRepository.find({
where: {
@@ -855,9 +855,9 @@ export class NoteCreateService implements OnApplicationShutdown {
});
for (const channelFollowing of channelFollowings) {
this.funoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
}
}
} else {
@@ -895,9 +895,9 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!following.withReplies) continue;
}
this.funoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
}
}
@@ -913,36 +913,36 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!userListMembership.withReplies) continue;
}
this.funoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
}
}
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
this.funoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
}
}
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
this.funoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
if (note.visibility === 'public' && note.userHost == null) {
this.funoutTimelineService.push('localTimelineWithReplies', note.id, 300, r);
this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r);
}
} else {
this.funoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
if (note.fileIds.length > 0) {
this.funoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
}
if (note.visibility === 'public' && note.userHost == null) {
this.funoutTimelineService.push('localTimeline', note.id, 1000, r);
this.fanoutTimelineService.push('localTimeline', note.id, 1000, r);
if (note.fileIds.length > 0) {
this.funoutTimelineService.push('localTimelineWithFiles', note.id, 500, r);
this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r);
}
}
}

View File

@@ -20,7 +20,7 @@ import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import type { Packed } from '@/misc/json-schema.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = {
@@ -87,6 +87,9 @@ export class RoleService implements OnApplicationShutdown {
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@@ -105,7 +108,7 @@ export class RoleService implements OnApplicationShutdown {
private globalEventService: GlobalEventService,
private idService: IdService,
private moderationLogService: ModerationLogService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
) {
//this.onMessage = this.onMessage.bind(this);
@@ -476,10 +479,10 @@ export class RoleService implements OnApplicationShutdown {
public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise<void> {
const roles = await this.getUserRoles(note.userId);
const redisPipeline = this.redisClient.pipeline();
const redisPipeline = this.redisForTimelines.pipeline();
for (const role of roles) {
this.funoutTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline);
this.fanoutTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline);
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
}

View File

@@ -29,7 +29,7 @@ import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import Logger from '../logger.js';
const logger = new Logger('following/create');
@@ -84,7 +84,7 @@ export class UserFollowingService implements OnModuleInit {
private webhookService: WebhookService,
private apRendererService: ApRendererService,
private accountMoveService: AccountMoveService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart,
) {
@@ -305,7 +305,7 @@ export class UserFollowingService implements OnModuleInit {
}
});
this.funoutTimelineService.purge(`homeTimeline:${follower.id}`);
this.fanoutTimelineService.purge(`homeTimeline:${follower.id}`);
}
// Publish followed event
@@ -374,7 +374,7 @@ export class UserFollowingService implements OnModuleInit {
}
});
this.funoutTimelineService.purge(`homeTimeline:${follower.id}`);
this.fanoutTimelineService.purge(`homeTimeline:${follower.id}`);
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {

View File

@@ -464,7 +464,7 @@ export class ApRendererService {
const attachment = profile.fields.map(field => ({
type: 'PropertyValue',
name: field.name,
value: /^https?:/.test(field.value)
value: (field.value.startsWith('http://') || field.value.startsWith('https://'))
? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>`
: field.value,
}));

View File

@@ -198,12 +198,14 @@ export class NotificationEntityService implements OnModuleInit {
});
} 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;
const packedUser = hint?.packedUsers != null ? hint.packedUsers.get(userId) : null;
if (packedUser) {
return packedUser;
}
return this.userEntityService.pack(userId, { id: meId }, {
detail: false,
});
}));
return await awaitAll({
id: notification.id,

View File

@@ -473,6 +473,7 @@ export class UserEntityService implements OnModuleInit {
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
unreadNotificationsCount: notificationsInfo?.unreadCount,
mutedWords: profile!.mutedWords,
hardMutedWords: profile!.hardMutedWords,
mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: [], // 後方互換性のため
notificationRecieveConfig: profile!.notificationRecieveConfig,

View File

@@ -446,6 +446,17 @@ export class MiMeta {
})
public enableActiveEmailValidation: boolean;
@Column('boolean', {
default: false,
})
public enableVerifymailApi: boolean;
@Column('varchar', {
length: 1024,
nullable: true,
})
public verifymailAuthKey: string | null;
@Column('boolean', {
default: true,
})
@@ -494,6 +505,11 @@ export class MiMeta {
})
public enableFanoutTimeline: boolean;
@Column('boolean', {
default: true,
})
public enableFanoutTimelineDbFallback: boolean;
@Column('integer', {
default: 300,
})

View File

@@ -215,7 +215,12 @@ export class MiUserProfile {
@Column('jsonb', {
default: [],
})
public mutedWords: string[][];
public mutedWords: (string[] | string)[];
@Column('jsonb', {
default: [],
})
public hardMutedWords: (string[] | string)[];
@Column('jsonb', {
default: [],

View File

@@ -42,11 +42,15 @@ export const packedAnnouncementSchema = {
type: 'string',
optional: false, nullable: false,
},
forYou: {
needConfirmationToRead: {
type: 'boolean',
optional: false, nullable: false,
},
needConfirmationToRead: {
silence: {
type: 'boolean',
optional: false, nullable: false,
},
forYou: {
type: 'boolean',
optional: false, nullable: false,
},

View File

@@ -19,7 +19,7 @@ export const packedChannelSchema = {
},
lastNotedAt: {
type: 'string',
optional: false, nullable: true,
nullable: true, optional: false,
format: 'date-time',
},
name: {
@@ -28,38 +28,18 @@ export const packedChannelSchema = {
},
description: {
type: 'string',
nullable: true, optional: false,
},
bannerUrl: {
type: 'string',
format: 'url',
nullable: true, optional: false,
},
isArchived: {
type: 'boolean',
optional: false, nullable: false,
},
notesCount: {
type: 'number',
nullable: false, optional: false,
},
usersCount: {
type: 'number',
nullable: false, optional: false,
},
isFollowing: {
type: 'boolean',
optional: true, nullable: false,
},
isFavorited: {
type: 'boolean',
optional: true, nullable: false,
optional: false, nullable: true,
},
userId: {
type: 'string',
nullable: true, optional: false,
format: 'id',
},
bannerUrl: {
type: 'string',
format: 'url',
nullable: true, optional: false,
},
pinnedNoteIds: {
type: 'array',
nullable: false, optional: false,
@@ -72,6 +52,18 @@ export const packedChannelSchema = {
type: 'string',
optional: false, nullable: false,
},
isArchived: {
type: 'boolean',
optional: false, nullable: false,
},
usersCount: {
type: 'number',
nullable: false, optional: false,
},
notesCount: {
type: 'number',
nullable: false, optional: false,
},
isSensitive: {
type: 'boolean',
optional: false, nullable: false,
@@ -80,5 +72,22 @@ export const packedChannelSchema = {
type: 'boolean',
optional: false, nullable: false,
},
isFollowing: {
type: 'boolean',
optional: true, nullable: false,
},
isFavorited: {
type: 'boolean',
optional: true, nullable: false,
},
pinnedNotes: {
type: 'array',
optional: true, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Note',
},
},
},
} as const;

View File

@@ -44,13 +44,13 @@ export const packedClipSchema = {
type: 'boolean',
optional: false, nullable: false,
},
isFavorited: {
type: 'boolean',
optional: true, nullable: false,
},
favoritedCount: {
type: 'number',
optional: false, nullable: false,
},
isFavorited: {
type: 'boolean',
optional: true, nullable: false,
},
},
} as const;

View File

@@ -74,7 +74,7 @@ export const packedDriveFileSchema = {
},
url: {
type: 'string',
optional: false, nullable: true,
optional: false, nullable: false,
format: 'url',
},
thumbnailUrl: {

View File

@@ -21,6 +21,12 @@ export const packedDriveFolderSchema = {
type: 'string',
optional: false, nullable: false,
},
parentId: {
type: 'string',
optional: false, nullable: true,
format: 'id',
example: 'xxxxxxxxxx',
},
foldersCount: {
type: 'number',
optional: true, nullable: false,
@@ -29,12 +35,6 @@ export const packedDriveFolderSchema = {
type: 'number',
optional: true, nullable: false,
},
parentId: {
type: 'string',
optional: false, nullable: true,
format: 'id',
example: 'xxxxxxxxxx',
},
parent: {
type: 'object',
optional: true, nullable: true,

View File

@@ -79,6 +79,10 @@ export const packedFederationInstanceSchema = {
type: 'string',
optional: false, nullable: true,
},
isSilenced: {
type: 'boolean',
optional: false, nullable: false,
},
iconUrl: {
type: 'string',
optional: false, nullable: true,
@@ -93,11 +97,6 @@ export const packedFederationInstanceSchema = {
type: 'string',
optional: false, nullable: true,
},
isSilenced: {
type: "boolean",
optional: false,
nullable: false,
},
infoUpdatedAt: {
type: 'string',
optional: false, nullable: true,

View File

@@ -22,6 +22,16 @@ export const packedFlashSchema = {
optional: false, nullable: false,
format: 'date-time',
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
title: {
type: 'string',
optional: false, nullable: false,
@@ -34,16 +44,6 @@ export const packedFlashSchema = {
type: 'string',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
likedCount: {
type: 'number',
optional: false, nullable: true,

View File

@@ -22,16 +22,16 @@ export const packedFollowingSchema = {
optional: false, nullable: false,
format: 'id',
},
followee: {
type: 'object',
optional: true, nullable: false,
ref: 'UserDetailed',
},
followerId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
followee: {
type: 'object',
optional: true, nullable: false,
ref: 'UserDetailed',
},
follower: {
type: 'object',
optional: true, nullable: false,

View File

@@ -22,14 +22,6 @@ export const packedGalleryPostSchema = {
optional: false, nullable: false,
format: 'date-time',
},
title: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: true,
},
userId: {
type: 'string',
optional: false, nullable: false,
@@ -40,6 +32,14 @@ export const packedGalleryPostSchema = {
ref: 'UserLite',
optional: false, nullable: false,
},
title: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: true,
},
fileIds: {
type: 'array',
optional: true, nullable: false,
@@ -70,5 +70,13 @@ export const packedGalleryPostSchema = {
type: 'boolean',
optional: false, nullable: false,
},
likedCount: {
type: 'number',
optional: false, nullable: false,
},
isLiked: {
type: 'boolean',
optional: true, nullable: false,
},
},
} as const;

View File

@@ -127,22 +127,26 @@ export const packedNoteSchema = {
channel: {
type: 'object',
optional: true, nullable: true,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
},
name: {
type: 'string',
optional: false, nullable: true,
},
isSensitive: {
type: 'boolean',
optional: true, nullable: false,
},
properties: {
id: {
type: 'string',
optional: false, nullable: false,
},
name: {
type: 'string',
optional: false, nullable: false,
},
color: {
type: 'string',
optional: false, nullable: false,
},
isSensitive: {
type: 'boolean',
optional: false, nullable: false,
},
allowRenoteToExternal: {
type: 'boolean',
optional: false, nullable: false,
},
},
},

View File

@@ -42,13 +42,9 @@ export const packedNotificationSchema = {
type: 'string',
optional: true, nullable: true,
},
choice: {
type: 'number',
optional: true, nullable: true,
},
invitation: {
type: 'object',
optional: true, nullable: true,
achievement: {
type: 'string',
optional: true, nullable: false,
},
body: {
type: 'string',
@@ -81,14 +77,14 @@ export const packedNotificationSchema = {
required: ['user', 'reaction'],
},
},
},
users: {
type: 'array',
optional: true, nullable: true,
items: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
users: {
type: 'array',
optional: true, nullable: true,
items: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
},
},
} as const;

View File

@@ -22,6 +22,32 @@ export const packedPageSchema = {
optional: false, nullable: false,
format: 'date-time',
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
content: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
},
},
variables: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
},
},
title: {
type: 'string',
optional: false, nullable: false,
@@ -34,23 +60,47 @@ export const packedPageSchema = {
type: 'string',
optional: false, nullable: true,
},
content: {
type: 'array',
hideTitleWhenPinned: {
type: 'boolean',
optional: false, nullable: false,
},
variables: {
type: 'array',
alignCenter: {
type: 'boolean',
optional: false, nullable: false,
},
userId: {
font: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user: {
type: 'object',
ref: 'UserLite',
script: {
type: 'string',
optional: false, nullable: false,
},
eyeCatchingImageId: {
type: 'string',
optional: false, nullable: true,
},
eyeCatchingImage: {
type: 'object',
optional: false, nullable: true,
ref: 'DriveFile',
},
attachedFiles: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'DriveFile',
},
},
likedCount: {
type: 'number',
optional: false, nullable: false,
},
isLiked: {
type: 'boolean',
optional: true, nullable: false,
},
},
} as const;

View File

@@ -49,11 +49,6 @@ export const packedUserLiteSchema = {
nullable: false, optional: false,
format: 'id',
},
url: {
type: 'string',
format: 'url',
nullable: false, optional: false,
},
angle: {
type: 'number',
nullable: false, optional: true,
@@ -62,19 +57,14 @@ export const packedUserLiteSchema = {
type: 'boolean',
nullable: false, optional: true,
},
url: {
type: 'string',
format: 'url',
nullable: false, optional: false,
},
},
},
},
isAdmin: {
type: 'boolean',
nullable: false, optional: true,
default: false,
},
isModerator: {
type: 'boolean',
nullable: false, optional: true,
default: false,
},
isBot: {
type: 'boolean',
nullable: false, optional: true,
@@ -83,12 +73,67 @@ export const packedUserLiteSchema = {
type: 'boolean',
nullable: false, optional: true,
},
instance: {
type: 'object',
nullable: false, optional: true,
properties: {
name: {
type: 'string',
nullable: true, optional: false,
},
softwareName: {
type: 'string',
nullable: true, optional: false,
},
softwareVersion: {
type: 'string',
nullable: true, optional: false,
},
iconUrl: {
type: 'string',
nullable: true, optional: false,
},
faviconUrl: {
type: 'string',
nullable: true, optional: false,
},
themeColor: {
type: 'string',
nullable: true, optional: false,
},
},
},
emojis: {
type: 'object',
nullable: false, optional: false,
},
onlineStatus: {
type: 'string',
format: 'url',
nullable: true, optional: false,
nullable: false, optional: false,
enum: ['unknown', 'online', 'active', 'offline'],
},
badgeRoles: {
type: 'array',
nullable: false, optional: true,
items: {
type: 'object',
nullable: false, optional: false,
properties: {
name: {
type: 'string',
nullable: false, optional: false,
},
iconUrl: {
type: 'string',
nullable: true, optional: false,
},
displayOrder: {
type: 'number',
nullable: false, optional: false,
},
},
},
},
},
} as const;
@@ -105,21 +150,18 @@ export const packedUserDetailedNotMeOnlySchema = {
format: 'uri',
nullable: true, optional: false,
},
movedToUri: {
movedTo: {
type: 'string',
format: 'uri',
nullable: true,
optional: false,
nullable: true, optional: false,
},
alsoKnownAs: {
type: 'array',
nullable: true,
optional: false,
nullable: true, optional: false,
items: {
type: 'string',
format: 'id',
nullable: false,
optional: false,
nullable: false, optional: false,
},
},
createdAt: {
@@ -249,6 +291,11 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
ffVisibility: {
type: 'string',
nullable: false, optional: false,
enum: ['public', 'followers', 'private'],
},
twoFactorEnabled: {
type: 'boolean',
nullable: false, optional: false,
@@ -264,6 +311,57 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false,
default: false,
},
roles: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'object',
nullable: false, optional: false,
properties: {
id: {
type: 'string',
nullable: false, optional: false,
format: 'id',
},
name: {
type: 'string',
nullable: false, optional: false,
},
color: {
type: 'string',
nullable: true, optional: false,
},
iconUrl: {
type: 'string',
nullable: true, optional: false,
},
description: {
type: 'string',
nullable: false, optional: false,
},
isModerator: {
type: 'boolean',
nullable: false, optional: false,
},
isAdministrator: {
type: 'boolean',
nullable: false, optional: false,
},
displayOrder: {
type: 'number',
nullable: false, optional: false,
},
},
},
},
memo: {
type: 'string',
nullable: true, optional: false,
},
moderationNote: {
type: 'string',
nullable: false, optional: true,
},
//#region relations
isFollowing: {
type: 'boolean',
@@ -297,10 +395,6 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean',
nullable: false, optional: true,
},
memo: {
type: 'string',
nullable: false, optional: true,
},
notify: {
type: 'string',
nullable: false, optional: true,
@@ -326,29 +420,37 @@ export const packedMeDetailedOnlySchema = {
nullable: true, optional: false,
format: 'id',
},
injectFeaturedNote: {
isModerator: {
type: 'boolean',
nullable: true, optional: false,
},
isAdmin: {
type: 'boolean',
nullable: true, optional: false,
},
injectFeaturedNote: {
type: 'boolean',
nullable: false, optional: false,
},
receiveAnnouncementEmail: {
type: 'boolean',
nullable: true, optional: false,
nullable: false, optional: false,
},
alwaysMarkNsfw: {
type: 'boolean',
nullable: true, optional: false,
nullable: false, optional: false,
},
autoSensitive: {
type: 'boolean',
nullable: true, optional: false,
nullable: false, optional: false,
},
carefulBot: {
type: 'boolean',
nullable: true, optional: false,
nullable: false, optional: false,
},
autoAcceptFollowed: {
type: 'boolean',
nullable: true, optional: false,
nullable: false, optional: false,
},
noCrawle: {
type: 'boolean',
@@ -387,10 +489,23 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
unreadAnnouncements: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'object',
nullable: false, optional: false,
ref: 'Announcement',
},
},
hasUnreadAntenna: {
type: 'boolean',
nullable: false, optional: false,
},
hasUnreadChannel: {
type: 'boolean',
nullable: false, optional: false,
},
hasUnreadNotification: {
type: 'boolean',
nullable: false, optional: false,
@@ -415,6 +530,18 @@ export const packedMeDetailedOnlySchema = {
},
},
},
hardMutedWords: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
},
},
},
mutedInstances: {
type: 'array',
nullable: true, optional: false,
@@ -429,12 +556,132 @@ export const packedMeDetailedOnlySchema = {
},
emailNotificationTypes: {
type: 'array',
nullable: true, optional: false,
nullable: false, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
},
},
achievements: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'object',
nullable: false, optional: false,
properties: {
name: {
type: 'string',
nullable: false, optional: false,
},
unlockedAt: {
type: 'number',
nullable: false, optional: false,
},
},
},
},
loggedInDays: {
type: 'number',
nullable: false, optional: false,
},
policies: {
type: 'object',
nullable: false, optional: false,
properties: {
gtlAvailable: {
type: 'boolean',
nullable: false, optional: false,
},
ltlAvailable: {
type: 'boolean',
nullable: false, optional: false,
},
canPublicNote: {
type: 'boolean',
nullable: false, optional: false,
},
canInvite: {
type: 'boolean',
nullable: false, optional: false,
},
inviteLimit: {
type: 'number',
nullable: false, optional: false,
},
inviteLimitCycle: {
type: 'number',
nullable: false, optional: false,
},
inviteExpirationTime: {
type: 'number',
nullable: false, optional: false,
},
canManageCustomEmojis: {
type: 'boolean',
nullable: false, optional: false,
},
canManageAvatarDecorations: {
type: 'boolean',
nullable: false, optional: false,
},
canSearchNotes: {
type: 'boolean',
nullable: false, optional: false,
},
canUseTranslator: {
type: 'boolean',
nullable: false, optional: false,
},
canHideAds: {
type: 'boolean',
nullable: false, optional: false,
},
driveCapacityMb: {
type: 'number',
nullable: false, optional: false,
},
alwaysMarkNsfw: {
type: 'boolean',
nullable: false, optional: false,
},
pinLimit: {
type: 'number',
nullable: false, optional: false,
},
antennaLimit: {
type: 'number',
nullable: false, optional: false,
},
wordMuteLimit: {
type: 'number',
nullable: false, optional: false,
},
webhookLimit: {
type: 'number',
nullable: false, optional: false,
},
clipLimit: {
type: 'number',
nullable: false, optional: false,
},
noteEachClipsLimit: {
type: 'number',
nullable: false, optional: false,
},
userListLimit: {
type: 'number',
nullable: false, optional: false,
},
userEachUserListsLimit: {
type: 'number',
nullable: false, optional: false,
},
rateLimitFactor: {
type: 'number',
nullable: false, optional: false,
},
},
},
//#region secrets
email: {
type: 'string',
@@ -511,5 +758,13 @@ export const packedUserSchema = {
type: 'object',
ref: 'UserDetailed',
},
{
type: 'object',
ref: 'UserDetailedNotMe',
},
{
type: 'object',
ref: 'MeDetailed',
},
],
} as const;

View File

@@ -24,6 +24,8 @@ import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-d
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js';
import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js';
import * as ep___admin_drive_files from './endpoints/admin/drive/files.js';
@@ -383,6 +385,8 @@ const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-de
const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default };
const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default };
const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default };
const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default };
const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default };
const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default };
const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default };
const $admin_drive_files: Provider = { provide: 'ep:admin/drive/files', useClass: ep___admin_drive_files.default };
@@ -746,6 +750,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar,
$admin_unsetUserBanner,
$admin_drive_cleanRemoteFiles,
$admin_drive_cleanup,
$admin_drive_files,
@@ -1103,6 +1109,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar,
$admin_unsetUserBanner,
$admin_drive_cleanRemoteFiles,
$admin_drive_cleanup,
$admin_drive_files,

View File

@@ -126,7 +126,7 @@ export class SignupApiService {
code: invitationCode,
});
if (ticket == null) {
if (ticket == null || ticket.usedById != null) {
reply.code(400);
return;
}

View File

@@ -24,6 +24,8 @@ import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-d
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js';
import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js';
import * as ep___admin_drive_files from './endpoints/admin/drive/files.js';
@@ -381,6 +383,8 @@ const eps = [
['admin/avatar-decorations/list', ep___admin_avatarDecorations_list],
['admin/avatar-decorations/update', ep___admin_avatarDecorations_update],
['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser],
['admin/unset-user-avatar', ep___admin_unsetUserAvatar],
['admin/unset-user-banner', ep___admin_unsetUserBanner],
['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles],
['admin/drive/cleanup', ep___admin_drive_cleanup],
['admin/drive/files', ep___admin_drive_files],

View File

@@ -22,7 +22,7 @@ export const paramDef = {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
publishing: { type: 'boolean', default: false },
publishing: { type: 'boolean', default: null, nullable: true },
},
required: [],
} as const;
@@ -37,8 +37,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId);
if (ps.publishing) {
if (ps.publishing === true) {
query.andWhere('ad.expiresAt > :now', { now: new Date() }).andWhere('ad.startsAt <= :now', { now: new Date() });
} else if (ps.publishing === false) {
query.andWhere('ad.expiresAt <= :now', { now: new Date() }).orWhere('ad.startsAt > :now', { now: new Date() });
}
const ads = await query.limit(ps.limit).getMany();

View File

@@ -267,6 +267,14 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
enableVerifymailApi: {
type: 'boolean',
optional: false, nullable: false,
},
verifymailAuthKey: {
type: 'string',
optional: false, nullable: true,
},
enableChartsForRemoteUser: {
type: 'boolean',
optional: false, nullable: false,
@@ -295,6 +303,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
enableFanoutTimelineDbFallback: {
type: 'boolean',
optional: false, nullable: false,
},
perLocalUserUserTimelineCacheMax: {
type: 'number',
optional: false, nullable: false,
@@ -417,6 +429,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
deeplIsPro: instance.deeplIsPro,
enableIpLogging: instance.enableIpLogging,
enableActiveEmailValidation: instance.enableActiveEmailValidation,
enableVerifymailApi: instance.enableVerifymailApi,
verifymailAuthKey: instance.verifymailAuthKey,
enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
enableServerMachineStats: instance.enableServerMachineStats,
@@ -424,6 +438,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
policies: { ...DEFAULT_POLICIES, ...instance.policies },
manifestJsonOverride: instance.manifestJsonOverride,
enableFanoutTimeline: instance.enableFanoutTimeline,
enableFanoutTimelineDbFallback: instance.enableFanoutTimelineDbFallback,
perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,

View File

@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
if (user == null) {
throw new Error('user not found');
}
if (user.avatarId == null) return;
await this.usersRepository.update(user.id, {
avatar: null,
avatarId: null,
avatarUrl: null,
avatarBlurhash: null,
});
this.moderationLogService.log(me, 'unsetUserAvatar', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
fileId: user.avatarId,
});
});
}
}

View File

@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
if (user == null) {
throw new Error('user not found');
}
if (user.bannerId == null) return;
await this.usersRepository.update(user.id, {
banner: null,
bannerId: null,
bannerUrl: null,
bannerBlurhash: null,
});
this.moderationLogService.log(me, 'unsetUserBanner', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
fileId: user.bannerId,
});
});
}
}

View File

@@ -113,6 +113,8 @@ export const paramDef = {
objectStorageS3ForcePathStyle: { type: 'boolean' },
enableIpLogging: { type: 'boolean' },
enableActiveEmailValidation: { type: 'boolean' },
enableVerifymailApi: { type: 'boolean' },
verifymailAuthKey: { type: 'string', nullable: true },
enableChartsForRemoteUser: { type: 'boolean' },
enableChartsForFederatedInstances: { type: 'boolean' },
enableServerMachineStats: { type: 'boolean' },
@@ -121,6 +123,7 @@ export const paramDef = {
preservedUsernames: { type: 'array', items: { type: 'string' } },
manifestJsonOverride: { type: 'string' },
enableFanoutTimeline: { type: 'boolean' },
enableFanoutTimelineDbFallback: { type: 'boolean' },
perLocalUserUserTimelineCacheMax: { type: 'integer' },
perRemoteUserUserTimelineCacheMax: { type: 'integer' },
perUserHomeTimelineCacheMax: { type: 'integer' },
@@ -453,6 +456,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.enableActiveEmailValidation = ps.enableActiveEmailValidation;
}
if (ps.enableVerifymailApi !== undefined) {
set.enableVerifymailApi = ps.enableVerifymailApi;
}
if (ps.verifymailAuthKey !== undefined) {
if (ps.verifymailAuthKey === '') {
set.verifymailAuthKey = null;
} else {
set.verifymailAuthKey = ps.verifymailAuthKey;
}
}
if (ps.enableChartsForRemoteUser !== undefined) {
set.enableChartsForRemoteUser = ps.enableChartsForRemoteUser;
}
@@ -485,6 +500,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.enableFanoutTimeline = ps.enableFanoutTimeline;
}
if (ps.enableFanoutTimelineDbFallback !== undefined) {
set.enableFanoutTimelineDbFallback = ps.enableFanoutTimelineDbFallback;
}
if (ps.perLocalUserUserTimelineCacheMax !== undefined) {
set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax;
}

View File

@@ -12,7 +12,8 @@ import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -70,7 +71,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private noteReadService: NoteReadService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -85,12 +87,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchAntenna);
}
this.antennasRepository.update(antenna.id, {
isActive: true,
lastUsedAt: new Date(),
});
// falseだった場合はアンテナの配信先が増えたことを通知したい
const needPublishEvent = !antenna.isActive;
let noteIds = await this.funoutTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId);
antenna.isActive = true;
antenna.lastUsedAt = new Date();
this.antennasRepository.update(antenna.id, antenna);
if (needPublishEvent) {
this.globalEventService.publishInternalEvent('antennaUpdated', antenna);
}
let noteIds = await this.fanoutTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) {
return [];

View File

@@ -12,9 +12,10 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { MetaService } from '@/core/MetaService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -69,15 +70,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
private cacheService: CacheService,
private activeUsersChart: ActiveUsersChart,
private metaService: MetaService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
const isRangeSpecified = untilId != null && sinceId != null;
const serverSettings = await this.metaService.fetch();
const channel = await this.channelsRepository.findOneBy({
id: ps.channelId,
});
@@ -88,14 +92,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) this.activeUsersChart.read(me);
if (isRangeSpecified || sinceId == null) {
if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) {
const [
userIdsWhoMeMuting,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
let noteIds = await this.funoutTimelineService.get(`channelTimeline:${channel.id}`, untilId, sinceId);
let noteIds = await this.fanoutTimelineService.get(`channelTimeline:${channel.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length > 0) {

View File

@@ -16,12 +16,9 @@ export const meta = {
requireCredential: false,
res: {
oneOf: [{
type: 'object',
ref: 'FederationInstance',
}, {
type: 'null',
}],
type: 'object',
optional: false, nullable: true,
ref: 'FederationInstance',
},
} as const;

View File

@@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { GalleryPostsRepository } from '@/models/_.js';
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
import { DI } from '@/di-symbols.js';
import { FeaturedService } from '@/core/FeaturedService.js';
export const meta = {
tags: ['gallery'],
@@ -27,25 +28,49 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {},
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
private galleryPostsRankingCache: string[] = [];
private galleryPostsRankingCacheLastFetchedAt = 0;
constructor(
@Inject(DI.galleryPostsRepository)
private galleryPostsRepository: GalleryPostsRepository,
private galleryPostEntityService: GalleryPostEntityService,
private featuredService: FeaturedService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.galleryPostsRepository.createQueryBuilder('post')
.andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) })
.andWhere('post.likedCount > 0')
.orderBy('post.likedCount', 'DESC');
let postIds: string[];
if (this.galleryPostsRankingCacheLastFetchedAt !== 0 && (Date.now() - this.galleryPostsRankingCacheLastFetchedAt < 1000 * 60 * 30)) {
postIds = this.galleryPostsRankingCache;
} else {
postIds = await this.featuredService.getGalleryPostsRanking(100);
this.galleryPostsRankingCache = postIds;
this.galleryPostsRankingCacheLastFetchedAt = Date.now();
}
const posts = await query.limit(10).getMany();
postIds.sort((a, b) => a > b ? -1 : 1);
if (ps.untilId) {
postIds = postIds.filter(id => id < ps.untilId!);
}
postIds = postIds.slice(0, ps.limit);
if (postIds.length === 0) {
return [];
}
const query = this.galleryPostsRepository.createQueryBuilder('post')
.where('post.id IN (:...postIds)', { postIds: postIds });
const posts = await query.getMany();
return await this.galleryPostEntityService.packMany(posts, me);
});

View File

@@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/_.js';
import { FeaturedService, GALLERY_POSTS_RANKING_WINDOW } from '@/core/FeaturedService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
@@ -57,6 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.galleryLikesRepository)
private galleryLikesRepository: GalleryLikesRepository,
private featuredService: FeaturedService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -88,6 +90,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
userId: me.id,
});
// ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
await this.featuredService.updateGalleryPostsRanking(post.id, 1);
}
this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1);
});
}

View File

@@ -6,6 +6,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { GalleryPostsRepository, GalleryLikesRepository } from '@/models/_.js';
import { FeaturedService, GALLERY_POSTS_RANKING_WINDOW } from '@/core/FeaturedService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
@@ -49,6 +51,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.galleryLikesRepository)
private galleryLikesRepository: GalleryLikesRepository,
private featuredService: FeaturedService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId });
@@ -68,6 +73,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Delete like
await this.galleryLikesRepository.delete(exist.id);
// ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
await this.featuredService.updateGalleryPostsRanking(post.id, -1);
}
this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1);
});
}

View File

@@ -123,6 +123,11 @@ export const meta = {
},
} as const;
const muteWords = { type: 'array', items: { oneOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'string' }
] } } as const;
export const paramDef = {
type: 'object',
properties: {
@@ -171,7 +176,8 @@ export const paramDef = {
autoSensitive: { type: 'boolean' },
ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
mutedWords: { type: 'array' },
mutedWords: muteWords,
hardMutedWords: muteWords,
mutedInstances: { type: 'array', items: {
type: 'string',
} },
@@ -234,16 +240,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.location !== undefined) profileUpdates.location = ps.location;
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility;
if (ps.mutedWords !== undefined) {
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
// TODO: ちゃんと数える
const length = JSON.stringify(ps.mutedWords).length;
if (length > (await this.roleService.getUserPolicies(user.id)).wordMuteLimit) {
const length = JSON.stringify(mutedWords).length;
if (length > limit) {
throw new ApiError(meta.errors.tooManyMutedWords);
}
}
// validate regular expression syntax
ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => {
const regexp = x.match(/^\/(.+)\/(.*)$/);
function validateMuteWordRegex(mutedWords: (string[] | string)[]) {
for (const mutedWord of mutedWords) {
if (typeof mutedWord !== "string") continue;
const regexp = mutedWord.match(/^\/(.+)\/(.*)$/);
if (!regexp) throw new ApiError(meta.errors.invalidRegexp);
try {
@@ -251,11 +261,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} catch (err) {
throw new ApiError(meta.errors.invalidRegexp);
}
});
}
}
if (ps.mutedWords !== undefined) {
checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit);
validateMuteWordRegex(ps.mutedWords);
profileUpdates.mutedWords = ps.mutedWords;
profileUpdates.enableWordMute = ps.mutedWords.length > 0;
}
if (ps.hardMutedWords !== undefined) {
checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit);
validateMuteWordRegex(ps.hardMutedWords);
profileUpdates.hardMutedWords = ps.hardMutedWords;
}
if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances;
if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig;
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
@@ -379,16 +399,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const newName = updates.name === undefined ? user.name : updates.name;
const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description;
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
if (newName != null) {
const tokens = mfm.parseSimple(newName);
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!));
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
}
if (newDescription != null) {
const tokens = mfm.parse(newDescription);
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!));
tags = extractHashtags(tokens!).map(tag => normalizeForSearch(tag)).splice(0, 32);
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
tags = extractHashtags(tokens).map(tag => normalizeForSearch(tag)).splice(0, 32);
}
for (const field of newFields) {
const nameTokens = mfm.parseSimple(field.name);
const valueTokens = mfm.parseSimple(field.value);
emojis = emojis.concat([
...extractCustomEmojisFromMfm(nameTokens),
...extractCustomEmojisFromMfm(valueTokens),
]);
}
updates.emojis = emojis;

View File

@@ -262,7 +262,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (renote.channelId && renote.channelId !== ps.channelId) {
// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
// リートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
const renoteChannel = await this.channelsRepository.findOneById(renote.channelId);
const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
if (renoteChannel == null) {
// リノートしたいノートが書き込まれているチャンネルが無い
throw new ApiError(meta.errors.noSuchChannel);

View File

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

View File

@@ -14,7 +14,7 @@ import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { QueryService } from '@/core/QueryService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MetaService } from '@/core/MetaService.js';
@@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
private cacheService: CacheService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
private queryService: QueryService,
private userFollowingService: UserFollowingService,
private metaService: MetaService,
@@ -93,99 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const serverSettings = await this.metaService.fetch();
if (serverSettings.enableFanoutTimeline) {
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let noteIds: string[];
let shouldFallbackToDb = false;
if (ps.withFiles) {
const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
`homeTimelineWithFiles:${me.id}`,
'localTimelineWithFiles',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
} else if (ps.withReplies) {
const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds]));
} else {
const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
shouldFallbackToDb = htlNoteIds.length === 0;
}
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
let redisTimeline: MiNote[] = [];
if (!shouldFallbackToDb) {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(redisTimeline, me);
} else { // fallback to db
return await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withReplies: ps.withReplies,
}, me);
}
} else {
if (!serverSettings.enableFanoutTimeline) {
return await this.getFromDb({
untilId,
sinceId,
@@ -197,6 +105,102 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withReplies: ps.withReplies,
}, me);
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let noteIds: string[];
let shouldFallbackToDb = false;
if (ps.withFiles) {
const [htlNoteIds, ltlNoteIds] = await this.fanoutTimelineService.getMulti([
`homeTimelineWithFiles:${me.id}`,
'localTimelineWithFiles',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
} else if (ps.withReplies) {
const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.fanoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds]));
} else {
const [htlNoteIds, ltlNoteIds] = await this.fanoutTimelineService.getMulti([
`homeTimeline:${me.id}`,
'localTimeline',
], untilId, sinceId);
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
shouldFallbackToDb = htlNoteIds.length === 0;
}
noteIds.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit);
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
let redisTimeline: MiNote[] = [];
if (!shouldFallbackToDb) {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(redisTimeline, me);
} else {
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
return await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withReplies: ps.withReplies,
}, me);
} else {
return [];
}
}
});
}

View File

@@ -14,7 +14,7 @@ import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { QueryService } from '@/core/QueryService.js';
import { MetaService } from '@/core/MetaService.js';
import { MiLocalUser } from '@/models/User.js';
@@ -69,7 +69,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
private cacheService: CacheService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
private queryService: QueryService,
private metaService: MetaService,
) {
@@ -84,84 +84,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const serverSettings = await this.metaService.fetch();
if (serverSettings.enableFanoutTimeline) {
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
let noteIds: string[];
if (ps.withFiles) {
noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId);
} else {
const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1);
}
noteIds = noteIds.slice(0, ps.limit);
let redisTimeline: MiNote[] = [];
if (noteIds.length > 0) {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (me && (note.userId === me.id)) {
return true;
}
if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false;
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me);
}
});
return await this.noteEntityService.packMany(redisTimeline, me);
} else { // fallback to db
return await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
withFiles: ps.withFiles,
withReplies: ps.withReplies,
}, me);
}
} else {
if (!serverSettings.enableFanoutTimeline) {
return await this.getFromDb({
untilId,
sinceId,
@@ -170,6 +93,87 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withReplies: ps.withReplies,
}, me);
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
let noteIds: string[];
if (ps.withFiles) {
noteIds = await this.fanoutTimelineService.get('localTimelineWithFiles', untilId, sinceId);
} else {
const [nonReplyNoteIds, replyNoteIds] = await this.fanoutTimelineService.getMulti([
'localTimeline',
'localTimelineWithReplies',
], untilId, sinceId);
noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1);
}
noteIds = noteIds.slice(0, ps.limit);
let redisTimeline: MiNote[] = [];
if (noteIds.length > 0) {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (me && (note.userId === me.id)) {
return true;
}
if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false;
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
return true;
});
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me);
}
});
return await this.noteEntityService.packMany(redisTimeline, me);
} else {
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
return await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
withFiles: ps.withFiles,
withReplies: ps.withReplies,
}, me);
} else {
return [];
}
}
});
}
@@ -182,7 +186,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}, me: MiLocalUser | null) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId)
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL) AND (note.channelId IS NULL)')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')

View File

@@ -14,7 +14,7 @@ import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MiLocalUser } from '@/models/User.js';
import { MetaService } from '@/core/MetaService.js';
@@ -65,7 +65,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
private cacheService: CacheService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
private userFollowingService: UserFollowingService,
private queryService: QueryService,
private metaService: MetaService,
@@ -76,77 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const serverSettings = await this.metaService.fetch();
if (serverSettings.enableFanoutTimeline) {
const [
followings,
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
let redisTimeline: MiNote[] = [];
if (noteIds.length > 0) {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId)) return false;
}
return true;
});
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(redisTimeline, me);
} else { // fallback to db
return await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me);
}
} else {
if (!serverSettings.enableFanoutTimeline) {
return await this.getFromDb({
untilId,
sinceId,
@@ -158,6 +88,80 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withRenotes: ps.withRenotes,
}, me);
}
const [
followings,
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.renoteMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]);
let noteIds = await this.fanoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
let redisTimeline: MiNote[] = [];
if (noteIds.length > 0) {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
redisTimeline = await query.getMany();
redisTimeline = redisTimeline.filter(note => {
if (note.userId === me.id) {
return true;
}
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) {
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
if (ps.withRenotes === false) return false;
}
}
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId)) return false;
}
return true;
});
redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
if (redisTimeline.length > 0) {
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(redisTimeline, me);
} else {
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
return await this.getFromDb({
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me);
} else {
return [];
}
}
});
}

View File

@@ -4,7 +4,8 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import type { MiNote, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import { Brackets } from 'typeorm';
import type { MiNote, MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
@@ -12,10 +13,11 @@ import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { QueryService } from '@/core/QueryService.js';
import { MiLocalUser } from '@/models/User.js';
import { MetaService } from '@/core/MetaService.js';
import { ApiError } from '../../error.js';
import { Brackets } from 'typeorm';
export const meta = {
tags: ['notes', 'lists'],
@@ -79,9 +81,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
private idService: IdService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
private queryService: QueryService,
private metaService: MetaService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -96,6 +98,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchList);
}
const serverSettings = await this.metaService.fetch();
if (!serverSettings.enableFanoutTimeline) {
return await this.getFromDb(list, {
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me);
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
@@ -106,7 +123,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.cacheService.userBlockedCache.fetch(me.id),
]);
let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId);
let noteIds = await this.fanoutTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
let redisTimeline: MiNote[] = [];
@@ -145,93 +162,119 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (redisTimeline.length > 0) {
this.activeUsersChart.read(me);
return await this.noteEntityService.packMany(redisTimeline, me);
} else { // fallback to db
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
.andWhere('note.channelId IS NULL') // チャンネルノートではない
.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}))
.orWhere(new Brackets(qb => {
qb // 返信だけど自分宛ての返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = :meId', { meId: me.id });
}))
.orWhere(new Brackets(qb => {
qb // 返信だけどwithRepliesがtrueの場合
.where('note.replyId IS NOT NULL')
.andWhere('userListMemberships.withReplies = true');
}));
}));
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
} else {
if (serverSettings.enableFanoutTimelineDbFallback) { // fallback to db
return await this.getFromDb(list, {
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me);
} else {
return [];
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
this.activeUsersChart.read(me);
return await this.noteEntityService.packMany(timeline, me);
}
});
}
private async getFromDb(list: MiUserList, ps: {
untilId: string | null,
sinceId: string | null,
limit: number,
includeMyRenotes: boolean,
includeRenotedMyNotes: boolean,
includeLocalRenotes: boolean,
withFiles: boolean,
withRenotes: boolean,
}, me: MiLocalUser) {
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
.andWhere('note.channelId IS NULL') // チャンネルノートではない
.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}))
.orWhere(new Brackets(qb => {
qb // 返信だけど自分宛ての返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = :meId', { meId: me.id });
}))
.orWhere(new Brackets(qb => {
qb // 返信だけどwithRepliesがtrueの場合
.where('note.replyId IS NOT NULL')
.andWhere('userListMemberships.withReplies = true');
}));
}));
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();
this.activeUsersChart.read(me);
return await this.noteEntityService.packMany(timeline, me);
}
}

View File

@@ -11,7 +11,7 @@ import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -66,7 +66,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -84,7 +84,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return [];
}
let noteIds = await this.funoutTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId);
let noteIds = await this.fanoutTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId);
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) {

View File

@@ -14,7 +14,8 @@ import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { QueryService } from '@/core/QueryService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { MetaService } from '@/core/MetaService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -70,7 +71,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
private cacheService: CacheService,
private idService: IdService,
private funoutTimelineService: FunoutTimelineService,
private fanoutTimelineService: FanoutTimelineService,
private metaService: MetaService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -78,7 +80,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const isRangeSpecified = untilId != null && sinceId != null;
const isSelf = me && (me.id === ps.userId);
if (isRangeSpecified || sinceId == null) {
const serverSettings = await this.metaService.fetch();
if (serverSettings.enableFanoutTimeline && (isRangeSpecified || sinceId == null)) {
const [
userIdsWhoMeMuting,
] = me ? await Promise.all([
@@ -86,9 +90,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
]) : [new Set<string>()];
const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
this.funoutTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId),
ps.withReplies ? this.funoutTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
ps.withChannelNotes ? this.funoutTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
this.fanoutTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId),
ps.withReplies ? this.fanoutTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
ps.withChannelNotes ? this.fanoutTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
]);
let noteIds = Array.from(new Set([

View File

@@ -4,7 +4,7 @@
*/
import type { Config } from '@/config.js';
import endpoints from '../endpoints.js';
import endpoints, { IEndpoint } from '../endpoints.js';
import { errors as basicErrors } from './errors.js';
import { schemas, convertSchemaToOpenApiSchema } from './schemas.js';
@@ -33,16 +33,17 @@ export function genOpenapiSpec(config: Config) {
schemas: schemas,
securitySchemes: {
ApiKeyAuth: {
type: 'apiKey',
in: 'body',
name: 'i',
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
},
},
};
for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) {
// 書き換えたりするのでディープコピーしておく。そのまま編集するとメモリ上の値が汚れて次回以降の出力に影響する
const copiedEndpoints = JSON.parse(JSON.stringify(endpoints)) as IEndpoint[];
for (const endpoint of copiedEndpoints.filter(ep => !ep.meta.secure)) {
const errors = {} as any;
if (endpoint.meta.errors) {
@@ -79,6 +80,13 @@ export function genOpenapiSpec(config: Config) {
schema.required = [...schema.required ?? [], 'file'];
}
if (schema.required && schema.required.length <= 0) {
// 空配列は許可されない
schema.required = undefined;
}
const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1);
const info = {
operationId: endpoint.name,
summary: endpoint.name,
@@ -92,17 +100,19 @@ export function genOpenapiSpec(config: Config) {
} : {}),
...(endpoint.meta.requireCredential ? {
security: [{
ApiKeyAuth: [],
bearerAuth: [],
}],
} : {}),
requestBody: {
required: true,
content: {
[requestType]: {
schema,
...(hasBody ? {
requestBody: {
required: true,
content: {
[requestType]: {
schema,
},
},
},
},
} : {}),
responses: {
...(endpoint.meta.res ? {
'200': {
@@ -118,6 +128,11 @@ export function genOpenapiSpec(config: Config) {
description: 'OK (without any results)',
},
}),
...(endpoint.meta.res?.optional === true || endpoint.meta.res?.nullable === true ? {
'204': {
description: 'OK (without any results)',
},
} : {}),
'400': {
description: 'Client error',
content: {
@@ -190,6 +205,7 @@ export function genOpenapiSpec(config: Config) {
};
spec.paths['/' + endpoint.name] = {
...(endpoint.meta.allowGet ? { get: info } : {}),
post: info,
};
}

View File

@@ -7,10 +7,16 @@ import type { Schema } from '@/misc/json-schema.js';
import { refs } from '@/misc/json-schema.js';
export function convertSchemaToOpenApiSchema(schema: Schema) {
const res: any = schema;
// optional, refはスキーマ定義に含まれないので分離しておく
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { optional, ref, ...res }: any = schema;
if (schema.type === 'object' && schema.properties) {
res.required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k);
const required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k);
if (required.length > 0) {
// 空配列は許可されない
res.required = required;
}
for (const k of Object.keys(schema.properties)) {
res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]);

View File

@@ -52,7 +52,7 @@ class LocalTimelineChannel extends Channel {
if (note.user.host !== null) return;
if (note.visibility !== 'public') return;
if (note.channelId != null && !this.followingChannels.has(note.channelId)) return;
if (note.channelId != null) return;
// 関係ない返信は除外
if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) {

View File

@@ -58,7 +58,7 @@ export class FeedService {
const feed = new Feed({
id: author.link,
title: `${author.name} (@${user.username}@${this.config.host})`,
updated: this.idService.parse(notes[0].id).date,
updated: notes.length !== 0 ? this.idService.parse(notes[0].id).date : undefined,
generator: 'Misskey',
description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
link: author.link,

View File

@@ -63,6 +63,8 @@ export const moderationLogTypes = [
'createAvatarDecoration',
'updateAvatarDecoration',
'deleteAvatarDecoration',
'unsetUserAvatar',
'unsetUserBanner',
] as const;
export type ModerationLogPayloads = {
@@ -237,6 +239,18 @@ export type ModerationLogPayloads = {
avatarDecorationId: string;
avatarDecoration: any;
};
unsetUserAvatar: {
userId: string;
userUsername: string;
userHost: string | null;
fileId: string;
};
unsetUserBanner: {
userId: string;
userUsername: string;
userHost: string | null;
fileId: string;
};
};
export type Serialized<T> = {

View File

@@ -93,7 +93,7 @@ describe('Webリソース', () => {
});
aliceChannel = await channel(alice, {});
bob = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
}, 1000 * 60 * 2);
afterAll(async () => {
@@ -152,6 +152,11 @@ describe('Webリソース', () => {
type,
}));
test('がGETできる。(ノートが存在しない場合でも。)', async () => await ok({
path: path(bob.username),
type,
}));
test('は存在しないユーザーはGETできない。', async () => await notOk({
path: path('nonexisting'),
status: 404,

View File

@@ -168,6 +168,7 @@ describe('ユーザー', () => {
hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
unreadAnnouncements: user.unreadAnnouncements,
mutedWords: user.mutedWords,
hardMutedWords: user.hardMutedWords,
mutedInstances: user.mutedInstances,
mutingNotificationTypes: user.mutingNotificationTypes,
notificationRecieveConfig: user.notificationRecieveConfig,

View File

@@ -94,6 +94,7 @@ describe('ActivityPub', () => {
cacheRemoteFiles: true,
cacheRemoteSensitiveFiles: true,
enableFanoutTimeline: true,
enableFanoutTimelineDbFallback: true,
perUserHomeTimelineCacheMax: 100,
perLocalUserUserTimelineCacheMax: 100,
perRemoteUserUserTimelineCacheMax: 100,

Binary file not shown.

Binary file not shown.

View File

@@ -9,7 +9,7 @@
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build",
"chromatic": "chromatic",
"test": "vitest --run",
"test": "vitest --run --globals",
"test-and-coverage": "vitest --run --coverage --globals",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
@@ -18,15 +18,15 @@
"dependencies": {
"@discordapp/twemoji": "14.1.2",
"@github/webauthn-json": "2.1.1",
"@rollup/plugin-alias": "5.0.1",
"@rollup/plugin-alias": "5.1.0",
"@rollup/plugin-json": "6.0.1",
"@rollup/plugin-replace": "5.0.5",
"@rollup/pluginutils": "5.0.5",
"@syuilo/aiscript": "0.16.0",
"@tabler/icons-webfont": "2.37.0",
"@vitejs/plugin-vue": "4.4.1",
"@vue-macros/reactivity-transform": "0.3.23",
"@vue/compiler-sfc": "3.3.8",
"@vitejs/plugin-vue": "4.5.0",
"@vue-macros/reactivity-transform": "0.4.0",
"@vue/compiler-sfc": "3.3.9",
"astring": "1.8.6",
"autosize": "6.0.1",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.6",
@@ -39,7 +39,7 @@
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "9.0.0",
"chromatic": "9.1.0",
"compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0",
@@ -57,7 +57,7 @@
"photoswipe": "5.4.2",
"punycode": "2.3.1",
"querystring": "0.2.1",
"rollup": "4.4.0",
"rollup": "4.6.0",
"sanitize-html": "2.11.0",
"shiki": "^0.14.5",
"sass": "1.69.5",
@@ -69,12 +69,12 @@
"tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typescript": "5.2.2",
"typescript": "5.3.2",
"uuid": "9.0.1",
"v-code-diff": "1.7.2",
"vanilla-tilt": "1.8.1",
"vite": "4.5.0",
"vue": "3.3.8",
"vite": "5.0.2",
"vue": "3.3.9",
"vuedraggable": "next"
},
"devDependencies": {
@@ -96,27 +96,27 @@
"@storybook/types": "7.5.3",
"@storybook/vue3": "7.5.3",
"@storybook/vue3-vite": "7.5.3",
"@testing-library/vue": "8.0.0",
"@testing-library/vue": "8.0.1",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5",
"@types/matter-js": "0.19.4",
"@types/micromatch": "4.0.5",
"@types/node": "20.9.0",
"@types/punycode": "2.1.2",
"@types/sanitize-html": "2.9.4",
"@types/matter-js": "0.19.5",
"@types/micromatch": "4.0.6",
"@types/node": "20.10.0",
"@types/punycode": "2.1.3",
"@types/sanitize-html": "2.9.5",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.7",
"@types/websocket": "1.0.9",
"@types/ws": "8.5.9",
"@typescript-eslint/eslint-plugin": "6.11.0",
"@typescript-eslint/parser": "6.11.0",
"@types/websocket": "1.0.10",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.12.0",
"@typescript-eslint/parser": "6.12.0",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.8",
"@vue/runtime-core": "3.3.9",
"acorn": "8.11.2",
"cross-env": "7.0.3",
"cypress": "13.5.0",
"eslint": "8.53.0",
"cypress": "13.6.0",
"eslint": "8.54.0",
"eslint-plugin-import": "2.29.0",
"eslint-plugin-vue": "9.18.1",
"fast-glob": "3.3.2",
@@ -128,7 +128,7 @@
"prettier": "3.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.2",
"start-server-and-test": "2.0.3",
"storybook": "7.5.3",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",

View File

@@ -204,12 +204,16 @@ export async function common(createVue: () => App<Element>) {
if (defaultStore.state.keepScreenOn) {
if ('wakeLock' in navigator) {
navigator.wakeLock.request('screen');
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
navigator.wakeLock.request('screen');
}
navigator.wakeLock.request('screen')
.then(() => {
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
navigator.wakeLock.request('screen');
}
});
})
.catch(() => {
// If Permission fails on an AppleDevice such as Safari
});
}
}

View File

@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<Mfm :text="report.comment"/>
</div>
<hr/>
<div>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></div>
<div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link">@{{ report.reporter.username }}</MkA></div>
<div v-if="report.assignee">
{{ i18n.ts.moderator }}:
<MkAcct :user="report.assignee"/>

View File

@@ -45,12 +45,12 @@ import contains from '@/scripts/contains.js';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
import { acct } from '@/filters/user.js';
import * as os from '@/os.js';
import { MFM_TAGS } from '@/scripts/mfm-tags.js';
import { defaultStore } from '@/store.js';
import { emojilist, getEmojiName } from '@/scripts/emojilist.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS } from '@/const.js';
type EmojiDef = {
emoji: string;
@@ -242,29 +242,7 @@ function exec() {
return;
}
const matched: EmojiDef[] = [];
const max = 30;
emojiDb.value.some(x => {
if (x.name.startsWith(props.q ?? '') && !x.aliasOf && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
return matched.length === max;
});
if (matched.length < max) {
emojiDb.value.some(x => {
if (x.name.startsWith(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
return matched.length === max;
});
}
if (matched.length < max) {
emojiDb.value.some(x => {
if (x.name.includes(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
return matched.length === max;
});
}
emojis.value = matched;
emojis.value = emojiAutoComplete(props.q, emojiDb.value);
} else if (props.type === 'mfmTag') {
if (!props.q || props.q === '') {
mfmTags.value = MFM_TAGS;
@@ -275,6 +253,78 @@ function exec() {
}
}
type EmojiScore = { emoji: EmojiDef, score: number };
function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
if (!query) {
return [];
}
const matched = new Map<string, EmojiScore>();
// 前方一致(エイリアスなし)
emojiDb.some(x => {
if (x.name.startsWith(query) && !x.aliasOf) {
matched.set(x.name, { emoji: x, score: query.length + 1 });
}
return matched.size === max;
});
// 前方一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
}
return matched.size === max;
});
}
// 部分一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
}
return matched.size === max;
});
}
// 簡易あいまい検索3文字以上
if (matched.size < max && query.length > 3) {
const queryChars = [...query];
const hitEmojis = new Map<string, EmojiScore>();
for (const x of emojiDb) {
// 文字列の位置を進めながら、クエリの文字を順番に探す
let pos = 0;
let hit = 0;
for (const c of queryChars) {
pos = x.name.indexOf(c, pos);
if (pos <= -1) break;
hit++;
}
// 半分以上の文字が含まれていればヒットとする
if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
}
}
// ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく6件オートコンプリートのポップアップのサイズ分
[...hitEmojis.values()]
.sort((x, y) => y.score - x.score)
.slice(0, 6)
.forEach(it => matched.set(it.emoji.name, it));
}
return [...matched.values()]
.sort((x, y) => y.score - x.score)
.slice(0, max)
.map(it => it.emoji);
}
function onMousedown(event: Event) {
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
}

View File

@@ -139,6 +139,10 @@ watch(v, () => {
height: 100%;
}
.textarea, .codeEditorHighlighter {
margin: 0;
}
.textarea {
position: absolute;
top: 0;
@@ -154,6 +158,8 @@ watch(v, () => {
background-color: transparent;
border: 0;
outline: 0;
min-width: calc(100% - 24px);
height: calc(100% - 24px);
padding: 12px;
line-height: 1.5em;
font-size: 1em;

View File

@@ -37,6 +37,7 @@ import * as Misskey from 'misskey-js';
import bytes from '@/filters/bytes.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import hasAudio from '@/scripts/media-has-audio.js';
const props = defineProps<{
video: Misskey.entities.DriveFile;
@@ -49,6 +50,12 @@ const videoEl = shallowRef<HTMLVideoElement>();
watch(videoEl, () => {
if (videoEl.value) {
videoEl.value.volume = 0.3;
hasAudio(videoEl.value).then(had => {
if (!had) {
videoEl.value.loop = videoEl.value.muted = true;
videoEl.value.play();
}
});
}
});
</script>

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!muted"
v-if="!hardMuted && !muted"
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
@@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</article>
</div>
<div v-else :class="$style.muted" @click="muted = false">
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
@@ -142,6 +142,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</I18n>
</div>
<div v-else>
<!--
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
so MkNote create empty div instead of no elements
-->
</div>
</template>
<script lang="ts" setup>
@@ -163,6 +169,7 @@ import { focusPrev, focusNext } from '@/scripts/focus.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import * as os from '@/os.js';
import * as sound from '@/scripts/sound.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
@@ -183,6 +190,7 @@ const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
pinned?: boolean;
mock?: boolean;
withHardMute?: boolean;
}>(), {
mock: false,
});
@@ -239,13 +247,23 @@ const urls = $computed(() => 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);
const muted = ref(checkMute(appearNote, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords));
const translation = ref<any>(null);
const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id));
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null)));
function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean {
if (mutedWords == null) return false;
if (checkWordMute(note, $i, mutedWords)) return true;
if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true;
if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true;
return false;
}
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
@@ -325,6 +343,8 @@ function react(viaKeyboard = false): void {
pleaseLogin();
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
sound.play('reaction');
if (props.mock) {
return;
}
@@ -343,6 +363,8 @@ function react(viaKeyboard = false): void {
} else {
blur();
reactionPicker.show(reactButton.value, reaction => {
sound.play('reaction');
if (props.mock) {
emit('reaction', reaction);
return;

View File

@@ -210,6 +210,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import * as os from '@/os.js';
import * as sound from '@/scripts/sound.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
@@ -369,6 +370,8 @@ function react(viaKeyboard = false): void {
pleaseLogin();
showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
sound.play('reaction');
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: '❤️',
@@ -383,6 +386,8 @@ function react(viaKeyboard = false): void {
} else {
blur();
reactionPicker.show(reactButton.value, reaction => {
sound.play('reaction');
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,

View File

@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:ad="true"
:class="$style.notes"
>
<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/>
</MkDateSeparatedList>
</div>
</template>

View File

@@ -15,7 +15,7 @@ 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"/>
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/>
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/>
</MkDateSeparatedList>
</template>

View File

@@ -114,7 +114,6 @@ const props = defineProps<{
& + article {
left: 0;
width: 100%;
}
}
}
@@ -124,6 +123,7 @@ const props = defineProps<{
> .thumbnail {
height: 80px;
overflow: clip;
}
> article {

View File

@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]"
@click="toggleReaction()"
>
<MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
<MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
<span :class="$style.count">{{ count }}</span>
</button>
</template>
@@ -28,6 +28,7 @@ import MkReactionEffect from '@/components/MkReactionEffect.vue';
import { claimAchievement } from '@/scripts/achievements.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import * as sound from '@/scripts/sound.js';
const props = defineProps<{
reaction: string;
@@ -59,6 +60,10 @@ async function toggleReaction() {
});
if (confirm.canceled) return;
if (oldReaction !== props.reaction) {
sound.play('reaction');
}
if (mock) {
emit('reactionToggled', props.reaction, (props.count - 1));
return;
@@ -75,6 +80,8 @@ async function toggleReaction() {
}
});
} else {
sound.play('reaction');
if (mock) {
emit('reactionToggled', props.reaction, (props.count + 1));
return;
@@ -188,7 +195,7 @@ if (!mock) {
}
}
.icon {
.limitWidth {
max-width: 150px;
}

View File

@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<iframe
ref="tweet"
allow="fullscreen;web-share"
sandbox="allow-popups allow-scripts allow-same-origin"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin"
scrolling="no"
:style="{ position: 'relative', width: '100%', height: `${tweetHeight}px`, border: 0 }"
:src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"

View File

@@ -7,6 +7,7 @@ import { VNode, h } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import MkUrl from '@/components/global/MkUrl.vue';
import MkTime from '@/components/global/MkTime.vue';
import MkLink from '@/components/MkLink.vue';
import MkMention from '@/components/MkMention.vue';
import MkEmoji from '@/components/global/MkEmoji.vue';
@@ -238,6 +239,34 @@ export default function(props: MfmProps) {
style = `background-color: #${color};`;
break;
}
case 'ruby': {
if (token.children.length === 1) {
const child = token.children[0];
const text = child.type === 'text' ? child.props.text : '';
return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]);
} else {
const rt = token.children.at(-1)!;
const text = rt.type === 'text' ? rt.props.text : '';
return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]);
}
}
case 'unixtime': {
const child = token.children[0];
const unixtime = parseInt(child.type === 'text' ? child.props.text : '');
return h('span', {
style: 'display: inline-block; font-size: 90%; border: solid 1px var(--divider); border-radius: 999px; padding: 4px 10px 4px 6px;',
}, [
h('i', {
class: 'ti ti-clock',
style: 'margin-right: 0.25em;',
}),
h(MkTime, {
key: Math.random(),
time: unixtime * 1000,
mode: 'detail',
}),
]);
}
}
if (style == null) {
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);

View File

@@ -28,12 +28,25 @@ const props = withDefaults(defineProps<{
mode: 'relative',
});
const _time = props.time == null ? NaN :
typeof props.time === 'number' ? props.time :
(props.time instanceof Date ? props.time : new Date(props.time)).getTime();
function getDateSafe(n: Date | string | number) {
try {
if (n instanceof Date) {
return n;
}
return new Date(n);
} catch (err) {
return {
getTime: () => NaN,
};
}
}
// eslint-disable-next-line vue/no-setup-props-destructure
const _time = props.time == null ? NaN : getDateSafe(props.time).getTime();
const invalid = Number.isNaN(_time);
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
// eslint-disable-next-line vue/no-setup-props-destructure
let now = $ref((props.origin ?? new Date()).getTime());
const ago = $computed(() => (now - _time) / 1000/*ms*/);
@@ -49,8 +62,15 @@ const relative = $computed<string>(() => {
ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) :
ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
ago >= -1 ? i18n.ts._ago.justNow :
i18n.ts._ago.future);
ago >= -3 ? i18n.ts._ago.justNow :
ago < -31536000 ? i18n.t('_timeIn.years', { n: Math.round(-ago / 31536000).toString() }) :
ago < -2592000 ? i18n.t('_timeIn.months', { n: Math.round(-ago / 2592000).toString() }) :
ago < -604800 ? i18n.t('_timeIn.weeks', { n: Math.round(-ago / 604800).toString() }) :
ago < -86400 ? i18n.t('_timeIn.days', { n: Math.round(-ago / 86400).toString() }) :
ago < -3600 ? i18n.t('_timeIn.hours', { n: Math.round(-ago / 3600).toString() }) :
ago < -60 ? i18n.t('_timeIn.minutes', { n: (~~(-ago / 60)).toString() }) :
i18n.t('_timeIn.seconds', { n: (~~(-ago % 60)).toString() })
);
});
let tickId: number;

View File

@@ -92,3 +92,5 @@ export const CURRENT_STICKY_BOTTOM = 'CURRENT_STICKY_BOTTOM';
export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error.jpg';
export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-found.jpg';
export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg';
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];

View File

@@ -65,9 +65,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<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/taichanNE30" target="_blank" :class="$style.contributor">
<a href="https://github.com/tai-cha" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/40626578?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@taichanNE30</span>
<span :class="$style.contributorUsername">@tai-cha</span>
</a>
</div>
</FormSection>

View File

@@ -122,6 +122,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkFolder>
<div>
<MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserAvatar"><i class="ti ti-user-circle"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton>
<MkButton v-if="iAmModerator" inline danger @click="unsetUserBanner"><i class="ti ti-photo"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
</div>
<MkButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton>
</div>
</FormSection>
@@ -320,6 +324,44 @@ async function toggleSuspend(v) {
}
}
async function unsetUserAvatar() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserAvatarConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-avatar', { userId: user.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
}
async function unsetUserBanner() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserBannerConfirm,
});
if (confirm.canceled) return;
const process = async () => {
await os.api('admin/unset-user-banner', { userId: user.id });
os.success();
};
await process().catch(err => {
os.alert({
type: 'error',
text: err.toString(),
});
});
refreshUser();
}
async function deleteAllFiles() {
const confirm = await os.confirm({
type: 'warning',

View File

@@ -9,12 +9,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<XHeader :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkSpacer :contentMax="900">
<MkSwitch :modelValue="publishing" @update:modelValue="onChangePublishing">
{{ i18n.ts.publishing }}
</MkSwitch>
<MkSelect v-model="filterType" :class="$style.input" @update:modelValue="filterItems">
<template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="publishing">{{ i18n.ts.publishing }}</option>
<option value="expired">{{ i18n.ts.expired }}</option>
</MkSelect>
<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" :key="ad.id" :specify="ad"/>
<MkInput v-model="ad.url" type="url">
<template #label>URL</template>
</MkInput>
@@ -82,14 +85,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { } from 'vue';
import { ref } 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';
import MkRadios from '@/components/MkRadios.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import FormSplit from '@/components/form/split.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
@@ -101,24 +104,34 @@ let ads: any[] = $ref([]);
const localTime = new Date();
const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000;
const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday];
let publishing = false;
const filterType = ref('all');
let publishing: boolean | null = null;
os.api('admin/ad/list', { publishing: publishing }).then(adsResponse => {
ads = adsResponse.map(r => {
const exdate = new Date(r.expiresAt);
const stdate = new Date(r.startsAt);
exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff);
stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff);
return {
...r,
expiresAt: exdate.toISOString().slice(0, 16),
startsAt: stdate.toISOString().slice(0, 16),
};
});
if (adsResponse != null) {
ads = adsResponse.map(r => {
const exdate = new Date(r.expiresAt);
const stdate = new Date(r.startsAt);
exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff);
stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff);
return {
...r,
expiresAt: exdate.toISOString().slice(0, 16),
startsAt: stdate.toISOString().slice(0, 16),
};
});
}
});
const onChangePublishing = (v) => {
publishing = v;
const filterItems = (v) => {
if (v === 'publishing') {
publishing = true;
} else if (v === 'expired') {
publishing = false;
} else {
publishing = null;
}
refresh();
};
@@ -197,6 +210,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 => {
if (adsResponse == null) return;
ads = ads.concat(adsResponse.map(r => {
const exdate = new Date(r.expiresAt);
const stdate = new Date(r.startsAt);
@@ -213,6 +227,7 @@ function more() {
function refresh() {
os.api('admin/ad/list', { publishing: publishing }).then(adsResponse => {
if (adsResponse == null) return;
ads = adsResponse.map(r => {
const exdate = new Date(r.expiresAt);
const stdate = new Date(r.startsAt);
@@ -252,4 +267,7 @@ definePageMetadata({
margin-bottom: var(--margin);
}
}
.input {
margin-bottom: 32px;
}
</style>

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