Compare commits

..

17 Commits

Author SHA1 Message Date
かっこかり 038623325c Revert "fix: OTel除去 (#17812)"
This reverts commit 6dac6f7928.
2026-07-29 09:40:14 +09:00
おさむのひと 6f579b6854 chore: docker_example.ymlのsentryForBackend更新が漏れていたのを追記 (#17817) 2026-07-28 21:25:08 +09:00
github-actions[bot] d600525bb2 Bump version to 2026.7.0-rc.0 2026-07-28 10:21:11 +00:00
おさむのひと 6dac6f7928 fix: OTel除去 (#17812) 2026-07-28 19:15:44 +09:00
かっこかり 318af2e1ff fix(frontend): follow-up of #17811 (#17814) 2026-07-28 17:35:33 +09:00
かっこかり a44e419d5a fix(frontend): follow-up of 2509a28 (#17815) 2026-07-28 17:35:18 +09:00
かっこかり 2509a28813 Merge commit from fork 2026-07-28 11:11:56 +09:00
おさむのひと a12ee2e5d7 Merge commit from fork 2026-07-28 11:09:37 +09:00
かっこかり 703aa2360b enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように (#17811)
* enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように

* clean up
2026-07-27 18:34:31 +09:00
かっこかり cb58ff32a4 fix(gh): check-misskey-js-autogenが失敗する問題を修正 (#17809)
* fix(gh): check-misskey-js-autogenが失敗する問題を修正

* fix comment

* fix
2026-07-27 16:48:36 +09:00
かっこかり f6979333d4 docs: Update CHANGELOG.md to include about js-yaml changes (#17805) 2026-07-27 00:14:34 +09:00
かっこかり e897255e71 fix 2026-07-25 21:43:41 +09:00
syuilo daf2a85539 Update CHANGELOG.md 2026-07-25 21:37:20 +09:00
chocolate-pie 1a59ec20e3 Merge commit from fork
* fix: Fix improper authorization in `admin/reset-password`

* Apply suggestions from code review

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>

* fix: improper authorization in `admin/unset-mfa`

* fix

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2026-07-25 21:34:46 +09:00
4ster1sk 3e31e59bcd fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように (#17802)
* test(e2e): フォロー中のチャンネルに再度フォローAPIを叩いた場合のテストを追加

* fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように

* docs(changelog): update changelog
2026-07-25 21:09:12 +09:00
anatawa12 982d4905c0 use UPSERT instead of select for hashtag statistics update (#17795)
* feat: set defaults for hashtag table

* chore: use upsert to update hashtag table

* fix: query runner not cleaned up

* fix: query runner not cleaned up correctly

* chore: replace await using => try-finally
2026-07-25 19:48:20 +09:00
renovate[bot] 7ecce0901c fix(deps): update dependency js-yaml to v5.2.2 [security] (#17797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 16:07:49 +09:00
36 changed files with 833 additions and 565 deletions
+23
View File
@@ -173,8 +173,31 @@ id: 'aidx'
#sentryForBackend:
# enableNodeProfiling: true
# # Specify Sentry integration names to disable individual auto-instrumentation.
# # The names are integration .name values, not factory function names.
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
# # Postgres ... DB queries (when pg is externalized)
# # Redis ... ioredis commands
# # Fastify ... inbound HTTP routes
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
# # NodeFetch ... fetch/undici requests
# disabledIntegrations: ['Postgres']
# options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
# # To intentionally propagate distributed traces to trusted internal services,
# # list only those internal URL patterns here.
# # Avoid broad patterns that match remote servers unless you intentionally
# # want to restore Sentry's legacy behavior of propagating traces to all
# # outbound HTTP requests.
# #tracePropagationTargets: []
# # Internal allowlist example:
# #tracePropagationTargets:
# # - 'internal-service.example'
#sentryForFrontend:
# vueIntegration:
@@ -0,0 +1,73 @@
name: Check Misskey JS autogen (comment)
on:
workflow_run:
types: [completed]
workflows:
- Check Misskey JS autogen # check-misskey-js-autogen.yml
jobs:
comment-misskey-js-autogen:
runs-on: ubuntu-latest
# No code is cloned or executed here, so it is safe to hold pull-requests: write
if: ${{ github.event.workflow_run.event == 'pull_request' }}
permissions:
actions: read
pull-requests: write
steps:
# The result is missing when the run failed before the check job (e.g. the PR does not build), so do not fail here
- name: Download result
id: download-result
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: misskey-js-autogen-result
path: result
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
# The artifact comes from a run that built fork code, so validate it before using
- name: Load result
id: load-result
if: steps.download-result.outcome == 'success'
run: |
pr_number="$(cat result/pr-number)"
changes="$(cat result/changes)"
case "$pr_number" in
''|*[!0-9]*) echo "invalid pr number"; exit 1;;
esac
case "$changes" in
true|false) ;;
*) echo "invalid result"; exit 1;;
esac
echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
echo "changes=$changes" >> "$GITHUB_OUTPUT"
- name: send message
if: steps.load-result.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
example:
```sh
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.load-result.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false
+17 -26
View File
@@ -1,7 +1,8 @@
# this name is used in check-misskey-js-autogen.comment.yml so be careful when change name
name: Check Misskey JS autogen
on:
pull_request_target:
pull_request:
branches:
- master
- develop
@@ -10,7 +11,6 @@ on:
- packages/backend/**
jobs:
# pull_request_target safety: permissions: read-all, and there are no secrets used in this job
generate-misskey-js:
runs-on: ubuntu-latest
permissions:
@@ -58,7 +58,6 @@ jobs:
name: generated-misskey-js
path: packages/misskey-js/generator/built/autogen
# pull_request_target safety: permissions: read-all, and no user codes are executed
get-actual-misskey-js:
runs-on: ubuntu-latest
permissions:
@@ -78,12 +77,11 @@ jobs:
name: actual-misskey-js
path: packages/misskey-js/src/autogen
# pull_request_target safety: nothing is cloned from repository
comment-misskey-js-autogen:
check-misskey-js-autogen:
runs-on: ubuntu-latest
needs: [generate-misskey-js, get-actual-misskey-js]
permissions:
pull-requests: write
contents: read
steps:
- name: download generated-misskey-js
uses: actions/download-artifact@v8
@@ -111,28 +109,21 @@ jobs:
- name: Print full diff
run: cat ./misskey-js.diff
- name: send message
if: steps.check-changes.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
# A fork's pull_request token cannot comment, so hand the result to check-misskey-js-autogen.comment.yml
- name: Save result
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
CHANGES: ${{ steps.check-changes.outputs.changes }}
run: |
mkdir -p result
echo "$PR_NUMBER" > result/pr-number
echo "$CHANGES" > result/changes
example:
```sh
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.check-changes.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
- name: Upload result
uses: actions/upload-artifact@v7
with:
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false
name: misskey-js-autogen-result
path: result
- name: Make failure if changes are detected
if: steps.check-changes.outputs.changes == 'true'
+6
View File
@@ -12,11 +12,14 @@
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。
- Docker Image は Node.js 26.4.0-trixie に更新されています。
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
- YAMLパーサーをアップデートし、より厳格なチェックが行われるようになりました。起動時にconfigの読み取りで構文エラーが発生する可能性があります。\
例えば `allowPrivateNetworks` を 2026.6.0 までの `example.yml` の構文を元に記述している場合は、配列の閉じ括弧のインデントを上げるか、リスト表示に書き換える必要があります。詳しくは https://github.com/misskey-dev/misskey/pull/17701 をご覧ください。
### General
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
- Enhance: 依存関係の更新
### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
@@ -31,6 +34,7 @@
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: ファイルアップロード前にプレビューできるように
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Enhance: 翻訳の更新
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
@@ -71,6 +75,8 @@
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
- Fix: フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように
- Fix: セキュリティに関する修正
## 2026.6.0
+1 -11
View File
@@ -1312,7 +1312,6 @@ noteOfThisUser: "このユーザーのノート一覧"
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
performance: "パフォーマンス"
modified: "変更あり"
modifiedAt: "変更日時"
discard: "破棄"
thereAreNChanges: "{n}件の変更があります"
signinWithPasskey: "パスキーでログイン"
@@ -1422,8 +1421,6 @@ append: "末尾に追加"
prepend: "先頭に追加"
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
latestBackupAt: "最終バックアップ"
latestSyncAt: "最終同期"
_imageEditing:
_vars:
@@ -1592,9 +1589,7 @@ _preferencesProfile:
useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "複数のデバイスで同期したい設定項目が存在する場合は、個別に「複数のデバイスで同期」オプションを有効にしてください。"
_preferencesBackup:
backupAndSync: "バックアップと同期"
autoBackup: "自動バックアップ"
autoBackup_description: "設定を自動でサーバーに保存し、いつでも復元できるようにします"
restoreFromBackup: "バックアップから復元"
noBackupsFoundTitle: "バックアップが見つかりませんでした"
noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。"
@@ -1602,12 +1597,7 @@ _preferencesBackup:
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。"
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。"
backupFound: "設定のバックアップが見つかりました"
forceBackup: "今すぐバックアップ"
autoSync: "デバイス間同期"
autoSync_description: "サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします"
forceSync: "今すぐ同期"
autoSyncAreYouSure: "デバイス間の同期をオンにしますか?"
autoSyncAreYouSure_description: "通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。"
forceBackup: "設定の強制バックアップ"
_accountSettings:
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-beta.6",
"version": "2026.7.0-rc.0",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -57,7 +57,7 @@
"esbuild": "0.28.1",
"execa": "9.6.1",
"ignore-walk": "9.0.0",
"js-yaml": "5.2.1",
"js-yaml": "5.2.2",
"tar": "7.5.21"
},
"devDependencies": {
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class HashtagTableDefaults1784899839024 {
name = 'HashtagTableDefaults1784899839024'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" SET DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" DROP DEFAULT`);
}
}
+1 -1
View File
@@ -193,7 +193,7 @@
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.3",
"js-yaml": "5.2.1",
"js-yaml": "5.2.2",
"pid-port": "2.1.1",
"rolldown": "1.1.5",
"simple-oauth2": "5.1.0",
@@ -13,6 +13,8 @@ import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js';
import type { MiLocalUser } from '@/models/User.js';
import { RedisKVCache } from '@/misc/cache.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
@Injectable()
export class ChannelFollowingService implements OnModuleInit {
@@ -96,11 +98,18 @@ export class ChannelFollowingService implements OnModuleInit {
requestUser: MiLocalUser,
targetChannel: MiChannel,
): Promise<void> {
await this.channelFollowingsRepository.insert({
id: this.idService.gen(),
followerId: requestUser.id,
followeeId: targetChannel.id,
});
try {
await this.channelFollowingsRepository.insert({
id: this.idService.gen(),
followerId: requestUser.id,
followeeId: targetChannel.id,
});
} catch (e) {
if (isDuplicateKeyValueError(e)) {
throw new IdentifiableError('6e335e39-0203-4418-a936-b3f2dc987845', 'already following');
}
throw e;
}
this.globalEventService.publishInternalEvent('followChannel', {
userId: requestUser.id,
+90 -114
View File
@@ -16,11 +16,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import Logger from '../logger.js';
const logger = new Logger('hashtag/create');
type AttachedOrMentioned = 'attached' | 'mentioned';
type UpdatingHashtagColumn = {
totalUserIds: keyof MiHashtag & `${AttachedOrMentioned}UserIds`,
totalUsersCount: keyof MiHashtag & `${AttachedOrMentioned}UsersCount`,
localUserIds: keyof MiHashtag & `${AttachedOrMentioned}LocalUserIds`,
localUsersCount: keyof MiHashtag & `${AttachedOrMentioned}LocalUsersCount`,
remoteUserIds: keyof MiHashtag & `${AttachedOrMentioned}RemoteUserIds`,
remoteUsersCount: keyof MiHashtag & `${AttachedOrMentioned}RemoteUsersCount`,
};
@Injectable()
export class HashtagService {
constructor(
@@ -68,126 +77,93 @@ export class HashtagService {
// TODO: サンプリング
this.updateHashtagsRanking(tag, user.id);
{
const index = await this.hashtagsRepository.findOneBy({ name: tag });
const column: UpdatingHashtagColumn = isUserAttached ? {
totalUserIds: 'attachedUserIds',
totalUsersCount: 'attachedUsersCount',
localUserIds: 'attachedLocalUserIds',
localUsersCount: 'attachedLocalUsersCount',
remoteUserIds: 'attachedRemoteUserIds',
remoteUsersCount: 'attachedRemoteUsersCount',
} : {
totalUserIds: 'mentionedUserIds',
totalUsersCount: 'mentionedUsersCount',
localUserIds: 'mentionedLocalUserIds',
localUsersCount: 'mentionedLocalUsersCount',
remoteUserIds: 'mentionedRemoteUserIds',
remoteUsersCount: 'mentionedRemoteUsersCount',
};
if (index == null && inc) {
try {
if (isUserAttached) {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [],
mentionedUsersCount: 0,
mentionedLocalUserIds: [],
mentionedLocalUsersCount: 0,
mentionedRemoteUserIds: [],
mentionedRemoteUsersCount: 0,
attachedUserIds: [user.id],
attachedUsersCount: 1,
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
} as MiHashtag);
} else {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [user.id],
mentionedUsersCount: 1,
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
attachedUserIds: [],
attachedUsersCount: 0,
attachedLocalUserIds: [],
attachedLocalUsersCount: 0,
attachedRemoteUserIds: [],
attachedRemoteUsersCount: 0,
} as MiHashtag);
}
return;
} catch (err) {
if (isDuplicateKeyValueError(err)) {
logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`);
} else {
throw err;
}
}
}
if (inc) {
await this.#incrementHashTag(user, tag, column);
} else {
await this.#decrementHashTag(user, tag, column);
}
}
async #incrementHashTag(
user: { id: MiUser['id']; host: MiUser['host']; },
tag: string,
columns: UpdatingHashtagColumn,
) {
const isLocal = this.userEntityService.isLocalUser(user);
const { totalUserIds, totalUsersCount } = columns;
const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
const runner = this.db.createQueryRunner('master');
try {
await runner.query(
`INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}",
"${localOrRemoteUserCount}")
VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1)
ON CONFLICT ("name")
DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)},
"${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)},
"${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)},
"${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)}`,
[tag, user.id, this.idService.gen()],
);
} finally {
await runner.release();
}
await this.db.transaction(async transactionalEntityManager => {
const transactionalHashtagRepository = transactionalEntityManager
.getRepository(MiHashtag);
function appendUserIdIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`): string {
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN array_append("hashtag"."${userIds}", $2) ELSE "hashtag"."${userIds}" END`;
}
const index = await transactionalHashtagRepository
.createQueryBuilder()
.setLock('pessimistic_write')
.where('name = :name', { name: tag })
.getOne();
function incrementCountIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN "hashtag"."${userCount}" + 1 ELSE "hashtag"."${userCount}" END`;
}
}
if (index == null) return;
async #decrementHashTag(
user: { id: MiUser['id']; host: MiUser['host']; },
tag: string,
columns: UpdatingHashtagColumn,
) {
const isLocal = this.userEntityService.isLocalUser(user);
const { totalUserIds, totalUsersCount } = columns;
const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
const set = {} as any;
const runner = this.db.createQueryRunner('master');
try {
await runner.query(
`UPDATE "hashtag"
SET "${totalUserIds}" = array_remove("${totalUserIds}", $2),
"${totalUsersCount}" = ${decrementIfExists(totalUserIds, totalUsersCount)},
"${localOrRemoteUserIds}" = array_remove("${localOrRemoteUserIds}", $2),
"${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)}
WHERE "name" = $1`,
[tag, user.id],
);
} finally {
await runner.release();
}
if (isUserAttached) {
if (inc) {
// 自分が初めてこのタグを使ったなら
if (!index.attachedUserIds.some(id => id === user.id)) {
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" + 1';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) {
set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) {
set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1';
}
} else {
set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" - 1';
if (this.userEntityService.isLocalUser(user)) {
set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1';
} else {
set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1';
}
}
} else {
// 自分が初めてこのタグを使ったなら
if (!index.mentionedUserIds.some(id => id === user.id)) {
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) {
set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`;
set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) {
set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`;
set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1';
}
}
if (Object.keys(set).length > 0) {
await transactionalHashtagRepository
.createQueryBuilder()
.update()
.where('id = :id', { id: index.id })
.set(set)
.execute();
}
});
function decrementIfExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
return `CASE WHEN ("${userIds}" @> ARRAY[$2]) THEN "${userCount}" - 1 ELSE "${userCount}" END`;
}
}
@bindThis
+6
View File
@@ -20,6 +20,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public mentionedUserIds: MiUser['id'][];
@@ -32,6 +33,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public mentionedLocalUserIds: MiUser['id'][];
@@ -44,6 +46,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public mentionedRemoteUserIds: MiUser['id'][];
@@ -56,6 +59,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public attachedUserIds: MiUser['id'][];
@@ -68,6 +72,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public attachedLocalUserIds: MiUser['id'][];
@@ -80,6 +85,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public attachedRemoteUserIds: MiUser['id'][];
@@ -10,6 +10,7 @@ import { ApiError } from '@/server/api/error.js';
import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { RoleService } from '@/core/RoleService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
@@ -25,10 +26,10 @@ export const meta = {
code: 'NO_SUCH_USER',
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
},
cannotResetPasswordOfRootUser: {
message: 'Cannot reset password of the root user.',
code: 'CANNOT_RESET_PASSWORD_OF_ROOT_USER',
id: 'f28fc207-42ca-44c7-a577-44b4f0ec5999',
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
},
},
@@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -75,8 +77,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser);
}
if (this.serverSettings.rootUserId === user.id) {
throw new ApiError(meta.errors.cannotResetPasswordOfRootUser);
if (await this.roleService.isAdministrator(user) && me.id !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
const passwd = secureRndstr(8);
@@ -11,6 +11,7 @@ import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import type { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
@@ -26,6 +27,11 @@ export const meta = {
code: 'NO_SUCH_USER',
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
},
},
} as const;
@@ -46,6 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -55,6 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser);
}
if (await this.roleService.isAdministrator(user) && me.id !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.db.transaction(async (transactionalEntityManager) => {
// パスキーを全て削除
await transactionalEntityManager.delete(MiUserSecurityKey, { userId: user.id });
@@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -25,6 +26,11 @@ export const meta = {
code: 'NO_SUCH_CHANNEL',
id: 'c0031718-d573-4e85-928e-10039f1fbb68',
},
alreadyFollowing: {
message: 'You are already following that channel.',
code: 'ALREADY_FOLLOWING',
id: '7db31665-651e-40c1-8e6e-28e9ad829a2d',
},
},
} as const;
@@ -52,7 +58,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchChannel);
}
await this.channelFollowingService.follow(me, channel);
try {
await this.channelFollowingService.follow(me, channel);
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === '6e335e39-0203-4418-a936-b3f2dc987845') throw new ApiError(meta.errors.alreadyFollowing);
}
throw e;
}
});
}
}
@@ -7,8 +7,11 @@ import Parser from 'rss-parser';
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { ApiError } from '../error.js';
const rssParser = new Parser();
const MAX_URL_LENGTH = 8192;
const MAX_RESPONSE_SIZE = 1024 * 1024;
const MAX_CONCURRENT_REQUESTS = 32;
export const meta = {
tags: ['meta'],
@@ -17,6 +20,34 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 3,
limit: {
duration: 60 * 1000,
max: 300,
},
errors: {
invalidUrl: {
message: 'Invalid URL.',
code: 'INVALID_URL',
id: '89b7ee05-ccfc-4bdd-9b13-61172fd1e06c',
httpStatusCode: 400,
},
fetchRssFailed: {
message: 'Failed to fetch RSS.',
code: 'FETCH_RSS_FAILED',
id: '8db5d3d8-31d7-452f-b0cc-ca3b8925de12',
kind: 'server',
httpStatusCode: 422,
},
fetchRssUnavailable: {
message: 'RSS fetching is temporarily unavailable.',
code: 'FETCH_RSS_UNAVAILABLE',
id: '91e6ff44-c63f-4725-9ad0-b7a40d7f7655',
kind: 'server',
httpStatusCode: 503,
},
},
res: {
type: 'object',
properties: {
@@ -215,21 +246,84 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
private readonly inFlightRequests = new Map<string, Promise<Awaited<ReturnType<Parser['parseString']>>>>();
private activeRequestCount = 0;
constructor(
private httpRequestService: HttpRequestService,
) {
super(meta, paramDef, async (ps, me) => {
const res = await this.httpRequestService.send(ps.url, {
method: 'GET',
headers: {
Accept: 'application/rss+xml, */*',
},
timeout: 5000,
});
super(meta, paramDef, async (ps) => {
const url = this.normalizeUrl(ps.url);
const inFlightRequest = this.inFlightRequests.get(url);
if (inFlightRequest != null) {
return await inFlightRequest;
}
const text = await res.text();
if (this.activeRequestCount >= MAX_CONCURRENT_REQUESTS) {
throw new ApiError(meta.errors.fetchRssUnavailable);
}
return rssParser.parseString(text);
this.activeRequestCount++;
const request = this.fetchRss(url)
.catch(() => {
throw new ApiError(meta.errors.fetchRssFailed);
})
.finally(() => {
this.inFlightRequests.delete(url);
this.activeRequestCount--;
});
this.inFlightRequests.set(url, request);
return await request;
});
}
private normalizeUrl(input: string): string {
if (input.length === 0 || input.length > MAX_URL_LENGTH) {
throw new ApiError(meta.errors.invalidUrl);
}
let url: URL;
try {
url = new URL(input);
} catch {
throw new ApiError(meta.errors.invalidUrl);
}
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username !== '' ||
url.password !== ''
) {
throw new ApiError(meta.errors.invalidUrl);
}
url.hash = '';
return url.href;
}
private async fetchRss(url: string): Promise<Awaited<ReturnType<Parser['parseString']>>> {
const res = await this.httpRequestService.send(url, {
method: 'GET',
headers: {
Accept: 'application/rss+xml, */*',
},
timeout: 5000,
size: MAX_RESPONSE_SIZE,
});
const finalUrl = new URL(res.url);
if (finalUrl.protocol !== 'http:' && finalUrl.protocol !== 'https:') {
throw new Error('Invalid final URL protocol');
}
const text = await res.text();
const rssParser = new Parser({
xml2js: {
async: true,
},
});
return await rssParser.parseString(text);
}
}
+36
View File
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test } from 'vitest';
import { api, castAsError, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
describe('Channel', () => {
let alice: misskey.entities.SignupResponse;
beforeAll(async () => {
alice = await signup({ username: 'alice' });
});
describe('Follow', () => {
let channel: misskey.entities.ChannelsCreateResponse;
beforeAll(async () => {
const res = await api('channels/create', { name: 'follow-test-channel' }, alice);
channel = res.body;
});
test('フォローしているチャンネルを再度フォローするとALREADY_FOLLOWINGエラーになる', async () => {
const res1 = await api('channels/follow', { channelId: channel.id }, alice);
assert.strictEqual(res1.status, 204);
const res2 = await api('channels/follow', { channelId: channel.id }, alice);
assert.strictEqual(res2.status, 400);
assert.strictEqual(castAsError(res2.body as any).error.code, 'ALREADY_FOLLOWING');
});
});
});
@@ -0,0 +1,192 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import FetchRssEndpoint, { meta } from '@/server/api/endpoints/fetch-rss.js';
import { ApiError } from '@/server/api/error.js';
import type { Mocked } from 'vitest';
import type { Response } from 'node-fetch';
const rssParserMocks = vi.hoisted(() => ({
constructor: vi.fn(),
parseString: vi.fn(),
}));
vi.mock('rss-parser', () => ({
default: class {
constructor(options: unknown) {
rssParserMocks.constructor(options);
}
public parseString(input: string) {
return rssParserMocks.parseString(input);
}
},
}));
const RSS = '<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title></channel></rss>';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function response(url: string, text = RSS): Response {
return {
url,
text: vi.fn().mockResolvedValue(text),
} as unknown as Response;
}
describe('fetch-rss endpoint', () => {
let httpRequestService: Mocked<HttpRequestService>;
let endpoint: FetchRssEndpoint;
beforeEach(() => {
rssParserMocks.constructor.mockReset();
rssParserMocks.parseString.mockReset();
rssParserMocks.parseString.mockResolvedValue({ items: [] });
httpRequestService = {
send: vi.fn(),
} as unknown as Mocked<HttpRequestService>;
endpoint = new FetchRssEndpoint(httpRequestService);
});
async function exec(url: string) {
return await endpoint.exec({ url }, null, null);
}
async function expectApiError(promise: Promise<unknown>, code: string, status: number) {
await expect(promise).rejects.toMatchObject({
code,
httpStatusCode: status,
info: undefined,
});
}
test.each([
'',
'not a URL',
'ftp://example.com/feed.xml',
`https://example.com/${'a'.repeat(8192)}`,
])('rejects invalid URL: %s', async (url) => {
await expectApiError(exec(url), 'INVALID_URL', 400);
expect(httpRequestService.send).not.toHaveBeenCalled();
});
test.each([
'https://user@example.com/feed.xml',
'https://user:password@example.com/feed.xml',
])('rejects URL containing credentials: %s', async (url) => {
await expectApiError(exec(url), 'INVALID_URL', 400);
expect(httpRequestService.send).not.toHaveBeenCalled();
});
test('does not expose details from internal errors', async () => {
httpRequestService.send.mockRejectedValue(new Error('secret upstream details'));
const promise = exec('https://example.com/feed.xml');
await expectApiError(promise, 'FETCH_RSS_FAILED', 422);
await expect(promise).rejects.not.toMatchObject({
message: expect.stringContaining('secret upstream details'),
});
});
test('passes the 1 MiB response limit to HttpRequestService', async () => {
httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml'));
await exec('https://example.com/feed.xml');
expect(httpRequestService.send).toHaveBeenCalledWith('https://example.com/feed.xml', expect.objectContaining({
size: 1024 * 1024,
}));
});
test('creates an asynchronous RSS parser for every request', async () => {
httpRequestService.send
.mockResolvedValueOnce(response('https://example.com/first.xml'))
.mockResolvedValueOnce(response('https://example.com/second.xml'));
await exec('https://example.com/first.xml');
await exec('https://example.com/second.xml');
expect(rssParserMocks.constructor).toHaveBeenCalledTimes(2);
expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(1, { xml2js: { async: true } });
expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(2, { xml2js: { async: true } });
});
test('rejects a non-HTTP final URL without exposing it', async () => {
httpRequestService.send.mockResolvedValue(response('file:///secret/feed.xml'));
await expectApiError(exec('https://example.com/feed.xml'), 'FETCH_RSS_FAILED', 422);
});
test('shares an in-flight request for the same normalized URL', async () => {
const pending = deferred<Response>();
httpRequestService.send.mockReturnValue(pending.promise);
const first = exec('HTTPS://EXAMPLE.COM:443/feed.xml#first');
const second = exec('https://example.com/feed.xml#second');
expect(httpRequestService.send).toHaveBeenCalledTimes(1);
pending.resolve(response('https://example.com/feed.xml'));
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(httpRequestService.send).toHaveBeenCalledTimes(1);
});
test('limits concurrent requests for different URLs to 32', async () => {
const pending = deferred<Response>();
httpRequestService.send.mockReturnValue(pending.promise);
const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`));
expect(httpRequestService.send).toHaveBeenCalledTimes(32);
await expectApiError(exec('https://example.com/overflow.xml'), 'FETCH_RSS_UNAVAILABLE', 503);
expect(httpRequestService.send).toHaveBeenCalledTimes(32);
pending.resolve(response('https://example.com/feed.xml'));
await Promise.all(requests);
httpRequestService.send.mockResolvedValue(response('https://example.com/after.xml'));
await expect(exec('https://example.com/after.xml')).resolves.toBeDefined();
});
test('cleans up the in-flight request and concurrency slot after success', async () => {
httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml'));
await exec('https://example.com/feed.xml');
await exec('https://example.com/feed.xml');
expect(httpRequestService.send).toHaveBeenCalledTimes(2);
});
test('cleans up the in-flight request and concurrency slot after failure', async () => {
httpRequestService.send.mockRejectedValue(new Error('upstream failed'));
const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`));
await Promise.allSettled(requests);
httpRequestService.send.mockResolvedValue(response('https://example.com/0.xml'));
await expect(exec('https://example.com/0.xml')).resolves.toBeDefined();
expect(httpRequestService.send).toHaveBeenCalledTimes(33);
});
test('has the expected rate limit metadata', () => {
expect(meta.limit).toEqual({
duration: 60 * 1000,
max: 300,
});
});
test('uses only the declared structured API errors', () => {
expect(new ApiError(meta.errors.invalidUrl)).toMatchObject({ code: 'INVALID_URL', httpStatusCode: 400 });
expect(new ApiError(meta.errors.fetchRssFailed)).toMatchObject({ code: 'FETCH_RSS_FAILED', httpStatusCode: 422 });
expect(new ApiError(meta.errors.fetchRssUnavailable)).toMatchObject({ code: 'FETCH_RSS_UNAVAILABLE', httpStatusCode: 503 });
});
});
-52
View File
@@ -1,52 +0,0 @@
# Preferences system
ユーザーの環境設定を管理するシステム。
## 指針
実装上のミスで、**設定値が意図せず失われる(古い値で上書きされる)ことが絶対にあってはならない。**
設定値が失われる、考えられるシナリオの例:
- 複数のタブでMisskeyを開いていて、タブAで設定を編集した後、タブBを開いたところ、タブBの古い状態の内容で設定が保存され、タブAで行った編集が巻き戻ってしまった。
- プロファイルの同期機能をオンにしたところ、サーバーに保存されていた古い設定でローカルが上書きされてしまった。
上記のシナリオが絶対に発生しない設計・実装にしなければならない。
上記以外にも考えられるシナリオがあれば、必ず適切な対処を行う設計・実装にしなければならない。
## 仕様
### タブ間同期
ブラウザで複数タブを開いているとき、あるタブで変更した設定が他のタブでも反映されるようにし、UXを向上させ、古い設定情報が上書き保存されることを防ぐ。
### 自動バックアップ(cloudBackup)
定期的、または特定のタイミングなどで、プロファイルをサーバーに自動的にアップロードする。
### デバイス間同期(cloudSync)
定期的、または特定のタイミングなどで、サーバーにアップロードされたプロファイルをダウンロードし、ローカルに適用する。
### 設定項目: デバイス間で同期(syncBetweenDevices, ValueMeta.sync)
デバイス間・プロファイル横断でその設定項目の設定値を同期する。
当オプションを有効にするかどうかは設定項目ごとに設定可能。
## ユーザーストーリー(ユースケース)
### 同じプロファイルを複数のデバイスで使いたい(同期したい)
autoBackupとautoSyncをオンにする。
### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい
継承したいプロファイルをバックアップから復元した後、プロファイルの名前を変える。
## メモ
autoBackupとautoSyncの違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「自動バックアップ:『しない/有効/有効+同期』」みたいな選択方式にするなどがいいかもしれない?
autoBackupはするがautoSyncしたくないケースはあまりないと思うが、一本化すると複数のデバイスで同一のプロファイルを共有しない人(大多数だと思われる)にとっては無駄に通信量が増えるだけになる
@@ -191,7 +191,6 @@ SPDX-License-Identifier: AGPL-3.0-only
role="menuitem"
tabindex="0"
:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
:disabled="unref(item.disabled)"
@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
@@ -630,7 +629,6 @@ function guardMouseMove(ev: MouseEvent) {
box-sizing: border-box;
max-width: 100vw;
min-width: 200px;
width: max-content;
overflow: auto;
overscroll-behavior: contain;
@@ -703,7 +701,6 @@ function guardMouseMove(ev: MouseEvent) {
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&.danger {
@@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, onUnmounted, watch } from 'vue';
import { computed } from 'vue';
import { isLink } from '@@/js/is-link.js';
import type { UploaderItem } from '@/composables/use-uploader.js';
import { getUploadName } from '@/composables/use-uploader.js';
@@ -57,6 +57,7 @@ import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import bytes from '@/filters/bytes.js';
import * as os from '@/os.js';
import type { Content } from '@/components/MkLightbox.item.vue';
const props = defineProps<{
items: UploaderItem[];
@@ -72,32 +73,6 @@ const emit = defineEmits<{
(ev: 'showMenuViaContextmenu', item: UploaderItem, event: PointerEvent): void;
}>();
//#region objectUrlMap
const objectUrlMap = new Map<UploaderItem, string>();
watch(() => props.items, () => {
for (const item of props.items) {
if (!objectUrlMap.has(item)) {
objectUrlMap.set(item, URL.createObjectURL(item.file));
}
}
for (const [item, url] of objectUrlMap.entries()) {
if (!props.items.includes(item)) {
URL.revokeObjectURL(url);
objectUrlMap.delete(item);
}
}
}, { immediate: true, deep: true });
onUnmounted(() => {
for (const url of objectUrlMap.values()) {
URL.revokeObjectURL(url);
}
objectUrlMap.clear();
});
//#endregion
function getUploadNameParts(item: UploaderItem): {
baseName: string;
extension: string | null;
@@ -127,13 +102,15 @@ function onContextmenu(item: UploaderItem, ev: PointerEvent) {
async function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
if (item.file.type.startsWith('image') || item.file.type.startsWith('video')) {
const contents = props.items.filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video')).map(item => ({
id: item.id,
type: (item.file.type.startsWith('video') ? 'video' as const : 'image' as const),
url: objectUrlMap.get(item)!,
thumbnailUrl: item.thumbnail,
filename: getUploadName(item),
}));
const contents = props.items
.filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video'))
.map<Content>(item => ({
id: item.id,
type: (item.file.type.startsWith('video') ? 'video' : 'image'),
url: item.objectUrl,
thumbnailUrl: item.thumbnail,
filename: getUploadName(item),
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: contents.findIndex(content => content.id === item.id),
@@ -16,6 +16,7 @@ import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import type { Content } from '@/components/MkLightbox.item.vue';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
@@ -74,6 +75,7 @@ export type UploaderItem = {
compressedSize?: number | null;
preprocessedFile?: Blob | null;
file: File;
objectUrl: string;
watermarkPreset: WatermarkPreset | null;
watermarkLayers: WatermarkLayers | null;
imageFrameParams: ImageFrameParams | null;
@@ -133,12 +135,13 @@ export function useUploader(options: {
const filename = file.name ?? 'untitled';
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
const watermarkPreset = uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? (prefer.s.watermarkPresets.find(p => p.id === prefer.s.defaultWatermarkPresetId) ?? null) : null;
const objectUrl = window.URL.createObjectURL(file);
items.value.push({
id,
name: prefer.s.keepOriginalFilename ? filename : id + extension,
suffix: '',
progress: null,
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? window.URL.createObjectURL(file) : null,
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? objectUrl : null,
preprocessing: false,
preprocessProgress: null,
uploading: false,
@@ -150,6 +153,7 @@ export function useUploader(options: {
watermarkLayers: watermarkPreset?.layers ?? null,
imageFrameParams: null,
file: markRaw(file),
objectUrl,
});
const reactiveItem = items.value.at(-1)!;
preprocess(reactiveItem).then(() => {
@@ -163,8 +167,24 @@ export function useUploader(options: {
}
}
function removeItem(item: UploaderItem) {
function revokeItemObjectUrls(item: UploaderItem) {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
URL.revokeObjectURL(item.objectUrl);
}
function createItemObjectUrl(item: UploaderItem, file: Blob | File): string {
revokeItemObjectUrls(item);
return window.URL.createObjectURL(file);
}
function updateItemObjectUrls(item: UploaderItem, file: Blob | File) {
const newObjectUrl = createItemObjectUrl(item, file);
item.objectUrl = newObjectUrl;
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? newObjectUrl : null;
}
function removeItem(item: UploaderItem) {
revokeItemObjectUrls(item);
items.value.splice(items.value.indexOf(item), 1);
}
@@ -214,7 +234,35 @@ export function useUploader(options: {
closed: () => dispose(),
});
},
}, {
});
if (item.file.type.startsWith('image/') || item.file.type.startsWith('video/')) {
menu.push({
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: async () => {
const contents = items.value
.filter(item => item.file.type.startsWith('image/') || item.file.type.startsWith('video/'))
.map<Content>(item => ({
id: item.id,
type: item.file.type.startsWith('video/') ? 'video' : 'image',
url: item.objectUrl,
thumbnail: item.thumbnail,
filename: getUploadName(item),
caption: item.caption ?? null,
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: contents.findIndex(x => x.id === item.id),
contents,
}, {
closed: () => dispose(),
});
},
});
}
menu.push({
type: 'divider',
});
}
@@ -235,11 +283,12 @@ export function useUploader(options: {
text: i18n.ts.cropImage,
action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
const newObjectUrl = createItemObjectUrl(item, cropped);
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(cropped),
thumbnail: window.URL.createObjectURL(cropped),
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(cropped.type) ? newObjectUrl : null,
objectUrl: newObjectUrl,
});
const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => {
@@ -260,11 +309,12 @@ export function useUploader(options: {
image: item.file,
}, {
ok: (file) => {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
const newObjectUrl = createItemObjectUrl(item, file);
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(file),
thumbnail: window.URL.createObjectURL(file),
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? newObjectUrl : null,
objectUrl: newObjectUrl,
});
const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => {
@@ -694,8 +744,7 @@ export function useUploader(options: {
imageBitmap.close();
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
updateItemObjectUrls(item, preprocessedFile);
item.preprocessedFile = markRaw(preprocessedFile);
}
@@ -754,14 +803,13 @@ export function useUploader(options: {
item.suffix = '';
}
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
updateItemObjectUrls(item, preprocessedFile);
item.preprocessedFile = markRaw(preprocessedFile);
}
function reset() {
for (const item of items.value) {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
revokeItemObjectUrls(item);
}
abortAll();
+2 -17
View File
@@ -144,17 +144,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="storagePersistenceSupported && !storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton>
<SearchMarker :keywords="['profile', 'preferences']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-cogs"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.preferencesProfile }}</SearchLabel></template>
<div class="_buttons">
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
<MkButton @click="forceCloudSync">{{ i18n.ts._preferencesBackup.forceSync }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
</div>
</SearchMarker>
</template>
@@ -180,7 +170,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue';
import { signout } from '@/signout.js';
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
import { suggestReload } from '@/utility/reload-suggest.js';
import { cloudBackup, cloudSync } from '@/preferences/utility.js';
import { cloudBackup } from '@/preferences/utility.js';
const $i = ensureSignin();
@@ -242,11 +232,6 @@ async function forceCloudBackup() {
os.success();
}
async function forceCloudSync() {
await cloudSync();
os.success();
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
+44 -19
View File
@@ -3,14 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { BroadcastChannel } from 'broadcast-channel';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
import type { StorageProvider } from '@/preferences/manager.js';
import { cloudBackup, cloudSync } from '@/preferences/utility.js';
import { cloudBackup } from '@/preferences/utility.js';
import { miLocalStorage } from '@/local-storage.js';
import { isSameScope, PreferencesManager } from '@/preferences/manager.js';
import { store } from '@/store.js';
import { $i } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { TAB_ID } from '@/tab-id.js';
// クラウド同期用グループ名
const syncGroup = 'default';
@@ -36,12 +38,11 @@ const io: StorageProvider = {
const cloudData = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'sync'],
key: syncGroup + ':' + ctx.key,
}) as [any, any, any][];
}) as [any, any][];
const target = cloudData.find(([scope]) => isSameScope(scope, ctx.scope));
if (target == null) return null;
return {
value: target[1],
meta: target[2],
};
} catch (err: any) {
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
@@ -53,12 +54,12 @@ const io: StorageProvider = {
},
cloudSet: async (ctx) => {
let cloudData: [any, any, any][] = [];
let cloudData: [any, any][] = [];
try {
cloudData = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'sync'],
key: syncGroup + ':' + ctx.key,
}) as [any, any, any][];
}) as [any, any][];
} catch (err: any) {
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
cloudData = [];
@@ -70,9 +71,9 @@ const io: StorageProvider = {
const i = cloudData.findIndex(([scope]) => isSameScope(scope, ctx.scope));
if (i === -1) {
cloudData.push([ctx.scope, ctx.value, ctx.meta]);
cloudData.push([ctx.scope, ctx.value]);
} else {
cloudData[i] = [ctx.scope, ctx.value, ctx.meta];
cloudData[i] = [ctx.scope, ctx.value];
}
await misskeyApi('i/registry/set', {
@@ -87,10 +88,10 @@ const io: StorageProvider = {
const fetchings = ctx.needs.map(need => io.cloudGet(need).then(res => [need.key, res] as const));
const cloudDatas = await Promise.all(fetchings);
const res = {} as Partial<Record<string, { value: any; meta: any; }>>;
const res = {} as Partial<Record<string, any>>;
for (const cloudData of cloudDatas) {
if (cloudData[1] != null) {
res[cloudData[0]] = cloudData[1];
res[cloudData[0]] = cloudData[1].value;
}
}
@@ -101,10 +102,41 @@ const io: StorageProvider = {
export const prefer = new PreferencesManager(io, $i);
//#region タブ間同期
window.addEventListener('storage', (ev) => {
if (ev.key === 'preferences') {
let latestPreferencesUpdate: {
tabId: string;
timestamp: number;
} | null = null;
const preferencesChannel = new BroadcastChannel<{
type: 'preferencesUpdate';
tabId: string;
timestamp: number;
}>('preferences');
prefer.on('committed', () => {
latestPreferencesUpdate = {
tabId: TAB_ID,
timestamp: Date.now(),
};
preferencesChannel.postMessage({
type: 'preferencesUpdate',
tabId: TAB_ID,
timestamp: latestPreferencesUpdate.timestamp,
});
});
preferencesChannel.addEventListener('message', (msg) => {
if (msg.type === 'preferencesUpdate') {
if (msg.tabId === TAB_ID) return;
if (latestPreferencesUpdate != null) {
if (msg.timestamp <= latestPreferencesUpdate.timestamp) return;
}
prefer.reloadProfile();
if (_DEV_) console.log('prefer: received update from other tab');
if (_DEV_) console.log('prefer:received update from other tab');
latestPreferencesUpdate = {
tabId: msg.tabId,
timestamp: msg.timestamp,
};
}
});
//#endregion
@@ -124,13 +156,6 @@ createVisibilityAwareInterval(() => {
}, 1000 * 60 * 3);
//#endregion
store.loaded.then(() => {
if (store.s.enablePreferencesAutoCloudSync) {
// TODO: 前回同期してから10分以上経過している場合のみ
cloudSync();
}
});
if (_DEV_) {
(window as any).prefer = prefer;
(window as any).cloudBackup = cloudBackup;
+45 -84
View File
@@ -36,7 +36,6 @@ type Scope = Partial<{
type ValueMeta = Partial<{
sync: boolean;
modifiedAt?: number; // 設定値を変更した日時。同期した日時などではない。つまり別のデバイスでA日に変更したものをB日に同期して取得したとしてもmodifiedAtはA日である必要がある
}>;
type PrefRecord<K extends keyof PREF> = [scope: Scope, value: ValueOf<K>, meta: ValueMeta];
@@ -75,7 +74,7 @@ export type PreferencesProfile = {
id: string;
version: string;
type: 'main';
modifiedAt: number; // 仕様が若干直感的ではない(syncされた値が降ってきたときは更新されないなど)ため、一応残してはいるが積極的な利用はしない方が無難。設定値の新旧比較が必要なら項目ごとのmodifiedAtを使うべし
modifiedAt: number;
name: string;
preferences: {
[K in keyof PREF]: PrefRecord<K>[];
@@ -89,9 +88,9 @@ export type PossiblyNonNormalizedPreferencesProfile = Omit<PreferencesProfile, '
export type StorageProvider = {
load: () => PossiblyNonNormalizedPreferencesProfile | null;
save: (ctx: { profile: PreferencesProfile; }) => void;
cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, { value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }>>>;
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; } | null>;
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }) => Promise<void>;
cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>;
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; } | null>;
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>;
};
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
@@ -103,6 +102,14 @@ type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) =>
export type PreferencesDefinition = Record<string, PreferencesDefinitionRecord<any>>;
type PreferencesManagerEvents = {
'committed': <K extends keyof PREF>(ctx: {
key: K;
value: ValueOf<K>;
oldValue: ValueOf<K>;
}) => void;
};
export function definePreferences<T extends Record<string, unknown>>(x: {
[K in keyof T]: PreferencesDefinitionRecord<T[K]>
}): {
@@ -181,44 +188,6 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi
return data as PreferencesProfile['preferences'];
}
// 各recordについて、modifiedAtが大きい方を採用する
// 引数の参照をmutateしないように注意すること
export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): PreferencesProfile {
const merged = {
...a,
modifiedAt: Math.max(a.modifiedAt, b.modifiedAt),
preferences: {},
} as PreferencesProfile;
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const aRecords = a.preferences[key];
const bRecords = b.preferences[key];
const mergedRecords = [...aRecords];
for (const bRecord of bRecords) {
const existingIndex = mergedRecords.findIndex(([scope]) => isSameScope(scope, bRecord[0]));
if (existingIndex === -1) {
mergedRecords.push(bRecord);
} else {
const aRecord = mergedRecords[existingIndex];
if ((bRecord[2].modifiedAt ?? 0) > (aRecord[2].modifiedAt ?? 0)) {
mergedRecords[existingIndex] = bRecord;
}
}
}
(merged.preferences[key] as PrefRecord<typeof key>[]) = mergedRecords;
}
return merged;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type PreferencesManagerEvents = {
};
// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない
// と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない
// NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる
@@ -278,13 +247,13 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
}
// TODO: desync対策 cloudの値のfetchが正常に完了していない状態でcommitすると多分値が上書きされる
public commit<K extends keyof PREF>(key: K, value: ValueOf<K>): PrefRecord<K> | null {
public commit<K extends keyof PREF>(key: K, value: ValueOf<K>) {
const currentAccount = this.currentAccount; // TSを黙らせるため
const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
if (deepEqual(this.s[key], v)) {
if (_DEV_) console.log('(skip) prefer:commit', key, v);
return null;
return;
}
if (_DEV_) console.log('prefer:commit', key, v);
@@ -293,40 +262,40 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
const record = this.getMatchedRecordOf(key);
const _save = () => {
this.save();
this.emit('committed', {
key,
value: v,
oldValue: this.s[key],
});
};
if (parseScope(record[0]).account == null && isAccountDependentKey(key) && currentAccount != null) {
const newRecord = [makeScope({
this.profile.preferences[key].push([makeScope({
server: host,
account: currentAccount.id,
}), v, {
modifiedAt: Date.now(),
}] as PrefRecord<K>;
this.profile.preferences[key].push(newRecord);
this.save();
return newRecord;
}), v, {}]);
_save();
return;
}
if (parseScope(record[0]).server == null && isServerDependentKey(key)) {
const newRecord = [makeScope({
this.profile.preferences[key].push([makeScope({
server: host,
}), v, {
modifiedAt: Date.now(),
}] as PrefRecord<K>;
this.profile.preferences[key].push(newRecord);
this.save();
return newRecord;
}), v, {}]);
_save();
return;
}
record[1] = v;
record[2].modifiedAt = Date.now();
this.save();
_save();
if (record[2].sync) {
// awaitの必要なし
// TODO: リクエストを間引く
this.io.cloudSet({ key, scope: record[0], value: record[1], meta: { modifiedAt: record[2].modifiedAt } });
this.io.cloudSet({ key, scope: record[0], value: record[1] });
}
return record;
}
/**
@@ -395,29 +364,24 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
const cloudValues = await this.io.cloudGetBulk({ needs });
let modified = false;
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const record = this.getMatchedRecordOf(key);
if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) {
const cloudValue = cloudValues[key];
if (!deepEqual(cloudValue, record[1])) {
this.rewriteRawState(key, cloudValue.value);
record[1] = cloudValue.value;
record[2].modifiedAt = cloudValue.meta.modifiedAt;
modified = true;
this.rewriteRawState(key, cloudValue);
record[1] = cloudValue;
if (_DEV_) console.log('cloud fetched', key, cloudValue);
}
}
}
if (modified) this.save();
this.save();
if (_DEV_) console.log('cloud fetch completed');
}
private save() {
public save() {
this.profile.modifiedAt = Date.now();
this.profile.version = version;
this.io.save({ profile: this.profile });
@@ -547,12 +511,12 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
newValue = resolvedValue;
}
const commitedRecord = this.commit(key, newValue);
this.commit(key, newValue);
const done = os.waiting();
try {
await this.io.cloudSet({ key, scope: record[0], value: newValue, meta: { modifiedAt: record[2].modifiedAt } });
await this.io.cloudSet({ key, scope: record[0], value: newValue });
} catch (err) {
done();
@@ -590,18 +554,20 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
}
public reloadProfile() {
const freshProfile = this.io.load();
if (freshProfile == null) return;
const newProfile = this.io.load();
if (newProfile == null) return;
this.profile = {
...freshProfile,
preferences: normalizePreferences(freshProfile.preferences, this.currentAccount),
...newProfile,
preferences: normalizePreferences(newProfile.preferences, this.currentAccount),
};
const states = this.genStates();
for (const _key in states) {
const key = _key as keyof PREF;
this.rewriteRawState(key, states[key]);
}
this.fetchCloudValues();
}
public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] {
@@ -651,11 +617,6 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
icon: 'ti ti-cloud-cog',
text: i18n.ts.syncBetweenDevices,
ref: sync,
}, {
type: 'divider',
}, {
type: 'label',
text: i18n.ts.modifiedAt + ': ' + (this.getMatchedRecordOf(key)[2].modifiedAt ? new Date(this.getMatchedRecordOf(key)[2].modifiedAt!).toLocaleString() : '-'),
}];
}
}
+7 -99
View File
@@ -3,8 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, ref, watch } from 'vue';
import { mergeProfiles } from './manager.js';
import { ref, watch } from 'vue';
import type { PreferencesProfile } from './manager.js';
import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
@@ -40,28 +39,6 @@ export function getPreferencesProfileMenu(): MenuItem[] {
cloudBackup();
} else {
store.set('enablePreferencesAutoCloudBackup', false);
autoSyncEnabled.value = false;
}
});
const autoSyncEnabled = ref(store.s.enablePreferencesAutoCloudSync);
watch(autoSyncEnabled, async () => {
if (autoSyncEnabled.value) {
const confirm = await os.confirm({
type: 'warning',
title: i18n.ts._preferencesBackup.autoSyncAreYouSure,
text: i18n.ts._preferencesBackup.autoSyncAreYouSure_description,
});
if (confirm.canceled) {
autoSyncEnabled.value = false;
return;
}
store.set('enablePreferencesAutoCloudSync', true);
} else {
store.set('enablePreferencesAutoCloudSync', false);
}
});
@@ -75,42 +52,10 @@ export function getPreferencesProfileMenu(): MenuItem[] {
renameProfile();
},
}, {
type: 'parent',
text: i18n.ts._preferencesBackup.backupAndSync,
caption: i18n.ts.latestBackupAt + ': ' + (store.s.latestPreferencesBackupAt !== 0 ? new Date(store.s.latestPreferencesBackupAt).toLocaleString() : '-'),
icon: 'ti ti-cloud',
children: [{
type: 'switch',
icon: 'ti ti-cloud-up',
text: i18n.ts._preferencesBackup.autoBackup,
caption: i18n.ts._preferencesBackup.autoBackup_description,
ref: autoBackupEnabled,
}, {
type: 'button',
icon: 'ti ti-cloud-up',
text: i18n.ts._preferencesBackup.forceBackup,
disabled: computed(() => !autoBackupEnabled.value),
action: () => {
cloudBackup();
},
}, {
type: 'divider',
}, {
type: 'switch',
icon: 'ti ti-cloud-down',
text: i18n.ts._preferencesBackup.autoSync,
caption: i18n.ts._preferencesBackup.autoSync_description,
ref: autoSyncEnabled,
disabled: computed(() => !autoBackupEnabled.value),
}, {
type: 'button',
icon: 'ti ti-cloud-down',
text: i18n.ts._preferencesBackup.forceSync,
disabled: computed(() => !autoSyncEnabled.value),
action: () => {
cloudSync();
},
}],
type: 'switch',
icon: 'ti ti-cloud-up',
text: i18n.ts._preferencesBackup.autoBackup,
ref: autoBackupEnabled,
}, {
text: i18n.ts.export,
icon: 'ti ti-download',
@@ -194,55 +139,17 @@ function importProfile() {
input.click();
}
export async function cloudSync() {
if ($i == null) return;
const cloudProfile = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'backups'],
key: prefer.profile.name,
}) as PreferencesProfile | null;
if (cloudProfile == null) {
if (_DEV_) console.log('no backuped profile found, skipping sync');
return;
}
if (_DEV_) console.log('backuped profile found, syncing', cloudProfile);
miLocalStorage.setItem('preferences', JSON.stringify(mergeProfiles(prefer.profile, cloudProfile)));
prefer.reloadProfile();
store.set('latestPreferencesSyncAt', Date.now());
}
export async function cloudBackup() {
if ($i == null) return;
if (!canAutoBackup()) {
throw new Error('cannot auto backup for this profile');
}
let currentProfile = prefer.profile;
if (_DEV_) console.log('cloud backup', currentProfile);
const backupedProfile = await misskeyApi('i/registry/get', {
scope: ['client', 'preferences', 'backups'],
key: prefer.profile.name,
}) as PreferencesProfile | null;
// 古い設定で新しいバックアップを上書きしないようにマージ
if (backupedProfile != null) {
currentProfile = mergeProfiles(currentProfile, backupedProfile);
}
await misskeyApi('i/registry/set', {
scope: ['client', 'preferences', 'backups'],
key: prefer.profile.name,
value: currentProfile,
value: prefer.profile,
});
store.set('latestPreferencesBackupAt', Date.now());
}
export async function listCloudBackups() {
@@ -279,6 +186,7 @@ export async function restoreFromCloudBackup() {
const select = await os.select({
title: i18n.ts._preferencesBackup.selectBackupToRestore,
text: '️ ' + i18n.ts._preferencesProfile.shareSameProfileBetweenDevicesIsNotRecommended + ' ' + i18n.ts._preferencesProfile.useSyncBetweenDevicesOptionIfYouWantToSyncSetting,
items: backups.map(backup => ({
label: backup.name,
value: backup.name,
-12
View File
@@ -110,10 +110,6 @@ export const store = markRaw(new Pizzax('base', {
where: 'device',
default: false,
},
enablePreferencesAutoCloudSync: {
where: 'device',
default: false,
},
showPreferencesAutoCloudBackupSuggestion: {
where: 'device',
default: true,
@@ -122,14 +118,6 @@ export const store = markRaw(new Pizzax('base', {
where: 'device',
default: true,
},
latestPreferencesBackupAt: {
where: 'device',
default: 0,
},
latestPreferencesSyncAt: {
where: 'device',
default: 0,
},
}));
// TODO: 他のタブと永続化されたstateを同期
-1
View File
@@ -23,7 +23,6 @@ export interface MenuButton {
danger?: boolean;
active?: boolean | ComputedRef<boolean>;
avatar?: Misskey.entities.User;
disabled?: boolean | Ref<boolean>;
action: MenuAction;
}
@@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { url as baseUrl } from '@@/js/config.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
import { shuffle } from '@/utility/shuffle.js';
@@ -53,7 +54,11 @@ const tick = () => {
if (props.shuffle) {
shuffle(feed.items);
}
items.value = feed.items;
items.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = new URL(item.link, baseUrl);
return ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
key.value++;
});
+5 -1
View File
@@ -81,7 +81,11 @@ const tick = () => {
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items;
rawItems.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = new URL(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
});
};
@@ -121,7 +121,11 @@ const tick = () => {
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items;
rawItems.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = new URL(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
key.value++;
});
+1 -1
View File
@@ -38,6 +38,6 @@
"tsx": "4.23.0"
},
"dependencies": {
"js-yaml": "5.2.1"
"js-yaml": "5.2.2"
}
}
+1 -41
View File
@@ -5260,10 +5260,6 @@ export interface Locale extends ILocale {
*
*/
"modified": string;
/**
*
*/
"modifiedAt": string;
/**
*
*/
@@ -5703,14 +5699,6 @@ export interface Locale extends ILocale {
* AND指定になりOR指定になります
*/
"urlPreviewSensitiveListDescription": string;
/**
*
*/
"latestBackupAt": string;
/**
*
*/
"latestSyncAt": string;
"_imageEditing": {
"_vars": {
/**
@@ -6315,18 +6303,10 @@ export interface Locale extends ILocale {
"useSyncBetweenDevicesOptionIfYouWantToSyncSetting": string;
};
"_preferencesBackup": {
/**
*
*/
"backupAndSync": string;
/**
*
*/
"autoBackup": string;
/**
*
*/
"autoBackup_description": string;
/**
*
*/
@@ -6356,29 +6336,9 @@ export interface Locale extends ILocale {
*/
"backupFound": string;
/**
*
*
*/
"forceBackup": string;
/**
*
*/
"autoSync": string;
/**
*
*/
"autoSync_description": string;
/**
*
*/
"forceSync": string;
/**
*
*/
"autoSyncAreYouSure": string;
/**
*
*/
"autoSyncAreYouSure_description": string;
};
"_accountSettings": {
/**
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2026.7.0-beta.6",
"version": "2026.7.0-rc.0",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
+9
View File
@@ -21879,6 +21879,15 @@ export interface operations {
'application/json': components['schemas']['Error'];
};
};
/** @description Too many requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
+9 -9
View File
@@ -24,8 +24,8 @@ importers:
specifier: 9.0.0
version: 9.0.0
js-yaml:
specifier: 5.2.1
version: 5.2.1
specifier: 5.2.2
version: 5.2.2
tar:
specifier: 7.5.21
version: 7.5.21
@@ -592,8 +592,8 @@ importers:
specifier: 10.0.3
version: 10.0.3
js-yaml:
specifier: 5.2.1
version: 5.2.1
specifier: 5.2.2
version: 5.2.2
pid-port:
specifier: 2.1.1
version: 2.1.1
@@ -1230,8 +1230,8 @@ importers:
packages/i18n:
dependencies:
js-yaml:
specifier: 5.2.1
version: 5.2.1
specifier: 5.2.2
version: 5.2.2
devDependencies:
'@types/node':
specifier: 26.1.1
@@ -6500,8 +6500,8 @@ packages:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
js-yaml@5.2.1:
resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==}
js-yaml@5.2.2:
resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==}
hasBin: true
jsbn@0.1.1:
@@ -15042,7 +15042,7 @@ snapshots:
dependencies:
argparse: 2.0.1
js-yaml@5.2.1:
js-yaml@5.2.2:
dependencies:
argparse: 2.0.1
+2
View File
@@ -62,6 +62,8 @@ minimumReleaseAgeExclude:
- "@fastify/static@10.1.2"
# Renovate security update: tar@7.5.21
- tar@7.5.21
# Renovate security update: js-yaml@5.2.2
- js-yaml@5.2.2
overrides:
'@aiscript-dev/aiscript-languageserver': '-'
'bullmq>ioredis': 5.11.1