;
+
+ // Reset mocks
+ jest.clearAllMocks();
+ });
+
+ afterEach(async () => {
+ await app.close();
+ });
+
+ describe('suspend', () => {
+ test('should suspend user and update database', async () => {
+ const user = await createUser();
+ const moderator = await createUser();
+
+ await userSuspendService.suspend(user, moderator);
+
+ // ユーザーが凍結されているかチェック
+ const suspendedUser = await usersRepository.findOneBy({ id: user.id });
+ expect(suspendedUser?.isSuspended).toBe(true);
+
+ // モデレーションログが記録されているかチェック
+ expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', {
+ userId: user.id,
+ userUsername: user.username,
+ userHost: user.host,
+ });
+ });
+
+ test('should mark follower relationships as suspended', async () => {
+ const user = await createUser();
+ const followee1 = await createUser();
+ const followee2 = await createUser();
+ const moderator = await createUser();
+
+ // ユーザーがフォローしている関係を作成
+ await createFollowing(user, followee1);
+ await createFollowing(user, followee2);
+
+ await userSuspendService.suspend(user, moderator);
+ await setTimeout(250);
+
+ // フォロー関係が論理削除されているかチェック
+ const followings = await followingsRepository.find({
+ where: { followerId: user.id },
+ });
+
+ expect(followings).toHaveLength(2);
+ followings.forEach(following => {
+ expect(following.isFollowerSuspended).toBe(true);
+ });
+ });
+
+ test('should publish internal event for suspension', async () => {
+ const user = await createUser();
+ const moderator = await createUser();
+
+ await userSuspendService.suspend(user, moderator);
+ await setTimeout(250);
+
+ // 内部イベントが発行されているかチェック(非同期処理のため少し待つ)
+ await setTimeout(100);
+
+ expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
+ 'userChangeSuspendedState',
+ { id: user.id, isSuspended: true },
+ );
+ });
+ });
+
+ describe('unsuspend', () => {
+ test('should unsuspend user and update database', async () => {
+ const user = await createUser({ isSuspended: true });
+ const moderator = await createUser();
+
+ await userSuspendService.unsuspend(user, moderator);
+ await setTimeout(250);
+
+ // ユーザーの凍結が解除されているかチェック
+ const unsuspendedUser = await usersRepository.findOneBy({ id: user.id });
+ expect(unsuspendedUser?.isSuspended).toBe(false);
+
+ // モデレーションログが記録されているかチェック
+ expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', {
+ userId: user.id,
+ userUsername: user.username,
+ userHost: user.host,
+ });
+ });
+
+ test('should restore follower relationships', async () => {
+ const user = await createUser({ isSuspended: true });
+ const followee1 = await createUser();
+ const followee2 = await createUser();
+ const moderator = await createUser();
+
+ // 凍結状態のフォロー関係を作成
+ await createFollowing(user, followee1, { isFollowerSuspended: true });
+ await createFollowing(user, followee2, { isFollowerSuspended: true });
+
+ await userSuspendService.unsuspend(user, moderator);
+ await setTimeout(250);
+
+ // フォロー関係が復元されているかチェック
+ const followings = await followingsRepository.find({
+ where: { followerId: user.id },
+ });
+
+ expect(followings).toHaveLength(2);
+ followings.forEach(following => {
+ expect(following.isFollowerSuspended).toBe(false);
+ });
+ });
+
+ test('should publish internal event for unsuspension', async () => {
+ const user = await createUser({ isSuspended: true });
+ const moderator = await createUser();
+
+ await userSuspendService.unsuspend(user, moderator);
+ await setTimeout(250);
+
+ // 内部イベントが発行されているかチェック(非同期処理のため少し待つ)
+ await setTimeout(100);
+
+ expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
+ 'userChangeSuspendedState',
+ { id: user.id, isSuspended: false },
+ );
+ });
+ });
+
+ describe('integration test: suspend and unsuspend cycle', () => {
+ test('should preserve follow relationships through suspend/unsuspend cycle', async () => {
+ const user = await createUser();
+ const followee1 = await createUser();
+ const followee2 = await createUser();
+ const moderator = await createUser();
+
+ // 初期のフォロー関係を作成
+ await createFollowing(user, followee1);
+ await createFollowing(user, followee2);
+
+ // 初期状態の確認
+ let followings = await followingsRepository.find({
+ where: { followerId: user.id },
+ });
+ expect(followings).toHaveLength(2);
+ followings.forEach(following => {
+ expect(following.isFollowerSuspended).toBe(false);
+ });
+
+ // 凍結
+ await userSuspendService.suspend(user, moderator);
+ await setTimeout(250);
+
+ // 凍結後の状態確認
+ followings = await followingsRepository.find({
+ where: { followerId: user.id },
+ });
+ expect(followings).toHaveLength(2);
+ followings.forEach(following => {
+ expect(following.isFollowerSuspended).toBe(true);
+ });
+
+ // 凍結解除
+ const suspendedUser = await usersRepository.findOneByOrFail({ id: user.id });
+ await userSuspendService.unsuspend(suspendedUser, moderator);
+ await setTimeout(250);
+
+ // 凍結解除後の状態確認
+ followings = await followingsRepository.find({
+ where: { followerId: user.id },
+ });
+ expect(followings).toHaveLength(2);
+ followings.forEach(following => {
+ expect(following.isFollowerSuspended).toBe(false);
+ });
+ });
+ });
+
+ describe('ActivityPub delivery', () => {
+ test('should deliver Delete activity on suspend of local user', async () => {
+ const localUser = await createUser({ host: null });
+ const moderator = await createUser();
+
+ userEntityService.isLocalUser.mockReturnValue(true);
+ userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`);
+ apRendererService.renderDelete.mockReturnValue({ type: 'Delete' } as any);
+ apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Delete' } as any);
+
+ await userSuspendService.suspend(localUser, moderator);
+ await setTimeout(250);
+
+ // ActivityPub配信が呼ばれているかチェック
+ expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser);
+ expect(apRendererService.renderDelete).toHaveBeenCalled();
+ expect(apRendererService.addContext).toHaveBeenCalled();
+ });
+
+ test('should deliver Undo Delete activity on unsuspend of local user', async () => {
+ const localUser = await createUser({ host: null, isSuspended: true });
+ const moderator = await createUser();
+
+ userEntityService.isLocalUser.mockReturnValue(true);
+ userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`);
+ apRendererService.renderDelete.mockReturnValue({ type: 'Delete' } as any);
+ apRendererService.renderUndo.mockReturnValue({ type: 'Undo' } as any);
+ apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Undo' } as any);
+
+ await userSuspendService.unsuspend(localUser, moderator);
+ await setTimeout(250);
+
+ // ActivityPub配信が呼ばれているかチェック
+ expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser);
+ expect(apRendererService.renderDelete).toHaveBeenCalled();
+ expect(apRendererService.renderUndo).toHaveBeenCalled();
+ expect(apRendererService.addContext).toHaveBeenCalled();
+ });
+
+ test('should not deliver any activity on suspend of remote user', async () => {
+ const remoteUser = await createUser({ host: 'remote.example.com' });
+ const moderator = await createUser();
+
+ userEntityService.isLocalUser.mockReturnValue(false);
+
+ await userSuspendService.suspend(remoteUser, moderator);
+ await setTimeout(250);
+
+ // ActivityPub配信が呼ばれていないことをチェック
+ expect(userEntityService.isLocalUser).toHaveBeenCalledWith(remoteUser);
+ expect(apRendererService.renderDelete).not.toHaveBeenCalled();
+ expect(queueService.deliver).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('remote user suspension', () => {
+ test('should suspend remote user without AP delivery', async () => {
+ const remoteUser = await createUser({ host: genHost() });
+ const moderator = await createUser();
+
+ await userSuspendService.suspend(remoteUser, moderator);
+ await setTimeout(250);
+
+ // ユーザーが凍結されているかチェック
+ const suspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
+ expect(suspendedUser?.isSuspended).toBe(true);
+
+ // モデレーションログが記録されているかチェック
+ expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', {
+ userId: remoteUser.id,
+ userUsername: remoteUser.username,
+ userHost: remoteUser.host,
+ });
+
+ // ActivityPub配信が呼ばれていないことを確認
+ expect(queueService.deliver).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('remote user unsuspension', () => {
+ test('should unsuspend remote user without AP delivery', async () => {
+ const remoteUser = await createUser({ host: genHost(), isSuspended: true });
+ const moderator = await createUser();
+
+ await userSuspendService.unsuspend(remoteUser, moderator);
+
+ await setTimeout(250);
+
+ // ユーザーの凍結が解除されているかチェック
+ const unsuspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
+ expect(unsuspendedUser?.isSuspended).toBe(false);
+
+ // モデレーションログが記録されているかチェック
+ expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', {
+ userId: remoteUser.id,
+ userUsername: remoteUser.username,
+ userHost: remoteUser.host,
+ });
+
+ // ActivityPub配信が呼ばれていないことを確認
+ expect(queueService.deliver).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts
index 4498a5e2b2..5c33c38f44 100644
--- a/packages/frontend-shared/js/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -112,6 +112,7 @@ export const ROLE_POLICIES = [
'chatAvailability',
'uploadableFileTypes',
'noteDraftLimit',
+ 'watermarkAvailable',
] as const;
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue
index 7d41740264..5b8211b715 100644
--- a/packages/frontend/src/components/MkNoteDraftsDialog.vue
+++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue
@@ -42,6 +42,13 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+
+ {{ i18n.ts.deletedNote }}
+
+
+
@@ -50,6 +57,13 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+
+ {{ i18n.ts.deletedNote }}
+
+
+
{{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue
index 98247f5d0f..c792ff3488 100644
--- a/packages/frontend/src/components/MkPullToRefresh.vue
+++ b/packages/frontend/src/components/MkPullToRefresh.vue
@@ -69,13 +69,13 @@ function getScreenY(event: TouchEvent | MouseEvent | PointerEvent): number {
function lockDownScroll() {
if (scrollEl == null) return;
scrollEl.style.touchAction = 'pan-x pan-down pinch-zoom';
- scrollEl.style.overscrollBehavior = 'none';
+ scrollEl.style.overscrollBehavior = 'auto none';
}
function unlockDownScroll() {
if (scrollEl == null) return;
scrollEl.style.touchAction = 'auto';
- scrollEl.style.overscrollBehavior = 'contain';
+ scrollEl.style.overscrollBehavior = 'auto contain';
}
function moveStartByMouse(event: MouseEvent) {
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
index aebec7a8f6..0f8713d4af 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+ {{ i18n.ts._role._options.watermarkAvailable }}
+
+ {{ i18n.ts._role.useBaseValue }}
+ {{ role.policies.watermarkAvailable.value ? i18n.ts.yes : i18n.ts.no }}
+
+
+
+
+ {{ i18n.ts._role.useBaseValue }}
+
+
+ {{ i18n.ts.enable }}
+
+
+ {{ i18n.ts._role.priority }}
+
+
+
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index dee0fb1e5c..e78a4bbc11 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -291,6 +291,14 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+ {{ i18n.ts._role._options.watermarkAvailable }}
+ {{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}
+
+ {{ i18n.ts.enable }}
+
+
{{ i18n.ts._role.new }}
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index 0614b1242b..1b99f6dea5 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
{{ i18n.ts.watermark }}
{{ i18n.ts._watermarkEditor.tip }}
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index 8042c7088c..747225af2f 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
- "version": "2025.7.0-beta.0",
+ "version": "2025.7.0-beta.2",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 40932afee3..11132bc037 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4401,7 +4401,9 @@ export type components = {
* @example xxxxxxxxxx
*/
renoteId?: string | null;
+ /** @description The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null. */
reply?: components['schemas']['Note'] | null;
+ /** @description The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null. */
renote?: components['schemas']['Note'] | null;
/** @enum {string} */
visibility: 'public' | 'home' | 'followers' | 'specified';
@@ -5225,6 +5227,7 @@ export type components = {
/** @enum {string} */
chatAvailability: 'available' | 'readonly' | 'unavailable';
noteDraftLimit: number;
+ watermarkAvailable: boolean;
};
ReversiGameLite: {
/** Format: id */
diff --git a/patches/typeorm.patch b/patches/typeorm.patch
new file mode 100644
index 0000000000..d5b4323781
--- /dev/null
+++ b/patches/typeorm.patch
@@ -0,0 +1,17 @@
+diff --git a/driver/postgres/PostgresDriver.js b/driver/postgres/PostgresDriver.js
+index 278f29c1f3deec4939bb4ed90e6edae167f704e0..9a84c3098dda915d6c33e24d925a8fa09af9095e 100644
+--- a/driver/postgres/PostgresDriver.js
++++ b/driver/postgres/PostgresDriver.js
+@@ -785,10 +785,10 @@ class PostgresDriver {
+ const tableColumnDefault = typeof tableColumn.default === "string"
+ ? JSON.parse(tableColumn.default.substring(1, tableColumn.default.length - 1))
+ : tableColumn.default;
+- return OrmUtils_1.OrmUtils.deepCompare(columnMetadata.default, tableColumnDefault);
++ return OrmUtils_1.OrmUtils.deepCompare(columnMetadata.default, tableColumnDefault ?? null);
+ }
+ const columnDefault = this.lowerDefaultValueIfNecessary(this.normalizeDefault(columnMetadata));
+- return columnDefault === tableColumn.default;
++ return columnDefault === tableColumn.default || columnDefault === undefined && tableColumn.default.toLowerCase() === 'null';
+ }
+ /**
+ * Normalizes "isUnique" value of the column.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2bb00d45cd..4cce64a0b5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,6 +9,11 @@ overrides:
lodash: 4.17.21
'@aiscript-dev/aiscript-languageserver': '-'
+patchedDependencies:
+ typeorm:
+ hash: 2677b97a423e157945c154e64183d3ae2eb44dfa9cb0e5ce731a7612f507bb56
+ path: patches/typeorm.patch
+
importers:
.:
@@ -422,7 +427,7 @@ importers:
version: 4.2.0
typeorm:
specifier: 0.3.24
- version: 0.3.24(ioredis@5.6.1)(pg@8.16.0)(reflect-metadata@0.2.2)
+ version: 0.3.24(patch_hash=2677b97a423e157945c154e64183d3ae2eb44dfa9cb0e5ce731a7612f507bb56)(ioredis@5.6.1)(pg@8.16.0)(reflect-metadata@0.2.2)
typescript:
specifier: 5.8.3
version: 5.8.3
@@ -21835,7 +21840,7 @@ snapshots:
typedarray@0.0.6: {}
- typeorm@0.3.24(ioredis@5.6.1)(pg@8.16.0)(reflect-metadata@0.2.2):
+ typeorm@0.3.24(patch_hash=2677b97a423e157945c154e64183d3ae2eb44dfa9cb0e5ce731a7612f507bb56)(ioredis@5.6.1)(pg@8.16.0)(reflect-metadata@0.2.2):
dependencies:
'@sqltools/formatter': 1.2.5
ansis: 3.17.0