diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2f6c9120441..fddf73ab7b4 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1220,7 +1220,9 @@ pinnedList: "ピン留めされたリスト" keepScreenOn: "デバイスの画面を常にオンにする" verifiedLink: "このリンク先の所有者であることが確認されました" notifyNotes: "投稿を通知" -unnotifyNotes: "投稿の通知を解除" +notifyAllNotes: "すべての投稿を通知" +notifyNotesWithFiles: "ファイル付き投稿のみ通知" +unnotifyNotes: "通知しない" notifyUsers: "投稿通知を設定したユーザー" authentication: "認証" authenticationRequiredToContinue: "続けるには認証を行ってください" diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 8ea1630d4d9..27158daa72e 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -770,10 +770,11 @@ export class NoteCreateService implements OnApplicationShutdown { // TODO: キャッシュ this.followingsRepository.findBy({ followeeId: user.id, - notify: 'normal', + notify: In(['normal', 'withFile']), }).then(async followings => { if (note.visibility !== 'specified') { const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false; + const hasFiles = data.files != null && data.files.length > 0; for (const following of followings) { // TODO: ワードミュート考慮 let isRenoteMuted = false; @@ -782,9 +783,11 @@ export class NoteCreateService implements OnApplicationShutdown { isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id); } if (!isRenoteMuted) { - this.notificationService.createNotification(following.followerId, 'note', { - noteId: note.id, - }, user.id); + if (following.notify === 'normal' || (following.notify === 'withFile' && hasFiles)) { + this.notificationService.createNotification(following.followerId, 'note', { + noteId: note.id, + }, user.id); + } } } } diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index fe621662877..d0686522b30 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -56,7 +56,7 @@ export class MiFollowing { length: 32, nullable: true, }) - public notify: 'normal' | null; + public notify: 'normal' | 'withFile' | null; //#region Denormalized fields @Index() diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f71ec1d023e..8dc9bf17ce6 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -436,7 +436,7 @@ export const packedUserDetailedNotMeOnlySchema = { notify: { type: 'string', nullable: false, optional: true, - enum: ['normal', 'none'], + enum: ['normal', 'withFile', 'none'], }, withReplies: { type: 'boolean', diff --git a/packages/backend/src/server/api/endpoints/following/update-all.ts b/packages/backend/src/server/api/endpoints/following/update-all.ts index c953feb3935..e6528779f10 100644 --- a/packages/backend/src/server/api/endpoints/following/update-all.ts +++ b/packages/backend/src/server/api/endpoints/following/update-all.ts @@ -29,7 +29,7 @@ export const meta = { export const paramDef = { type: 'object', properties: { - notify: { type: 'string', enum: ['normal', 'none'] }, + notify: { type: 'string', enum: ['normal', 'withFile', 'none'] }, withReplies: { type: 'boolean' }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/following/update.ts b/packages/backend/src/server/api/endpoints/following/update.ts index d62cf210ede..884ffbe6de7 100644 --- a/packages/backend/src/server/api/endpoints/following/update.ts +++ b/packages/backend/src/server/api/endpoints/following/update.ts @@ -56,7 +56,7 @@ export const paramDef = { type: 'object', properties: { userId: { type: 'string', format: 'misskey:id' }, - notify: { type: 'string', enum: ['normal', 'none'] }, + notify: { type: 'string', enum: ['normal', 'withFile', 'none'] }, withReplies: { type: 'boolean' }, }, required: ['userId'], diff --git a/packages/backend/test/e2e/note-notify.ts b/packages/backend/test/e2e/note-notify.ts index b3d5d110b9b..d567d46356c 100644 --- a/packages/backend/test/e2e/note-notify.ts +++ b/packages/backend/test/e2e/note-notify.ts @@ -6,18 +6,20 @@ import * as assert from 'node:assert'; import { setTimeout } from 'node:timers/promises'; import { describe, beforeAll, test } from 'vitest'; -import { api, signup } from '../utils.js'; +import { api, signup, uploadUrl } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('following/list', () => { let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse; + let dave: misskey.entities.SignupResponse; beforeAll(async () => { alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); }, 1000 * 60 * 2); test('通知設定なしのフォローのみの場合、空配列が返る', async () => { @@ -64,6 +66,35 @@ describe('following/list', () => { assert.deepStrictEqual(ids, [bob.id, carol.id].sort()); }); + test('withFile 設定のユーザーも notification: true な一覧に含まれる', async () => { + // alice が dave をフォローして withFile 通知ON + await api('following/create', { userId: dave.id }, alice); + await api('following/update', { userId: dave.id, notify: 'withFile' }, alice); + + const res = await api('following/list', { notification: true }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.length, 3); + + const ids = res.body.map((u) => u.followeeId).sort(); + assert.deepStrictEqual(ids, [bob.id, carol.id, dave.id].sort()); + }); + + test('withFile から none に変更すると notification: true な一覧から外れる', async () => { + await api('following/update', { userId: dave.id, notify: 'none' }, alice); + + const res = await api('following/list', { notification: true }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.length, 2); + + const ids = res.body.map((u) => u.followeeId); + assert.strictEqual(ids.includes(dave.id), false); + + // 後片付け: alice → dave のフォローを解除 + await api('following/delete', { userId: dave.id }, alice); + }); + test('通知設定をOFF(none)にすると notification: true な一覧から外れる', async () => { await api('following/update', { userId: bob.id, notify: 'none' }, alice); @@ -125,6 +156,54 @@ describe('following/list', () => { await api('notifications/mark-all-as-read', {}, alice); }); + test('withFile通知設定時、ファイル付き投稿で通知が届く', async () => { + await api('following/update', { userId: bob.id, notify: 'withFile' }, alice); + + // 既存の通知をクリア + await api('notifications/mark-all-as-read', {}, alice); + + // --- ケース1: テキストのみの投稿 → 通知が来ないこと --- + const textOnlyRes = await api('notes/create', { + text: 'ファイルなしの投稿', + }, bob); + assert.strictEqual(textOnlyRes.status, 200); + + // redisに追加されるのを待つ + await setTimeout(100); + + const beforeRes = await api('i/notifications', {}, alice); + assert.strictEqual(beforeRes.status, 200); + const noteNotifsBefore = beforeRes.body.filter((n: { type: string; note?: { id: string } }) => + n.type === 'note' && n.note?.id === textOnlyRes.body.createdNote.id, + ); + assert.strictEqual(noteNotifsBefore.length, 0, 'ファイルなし投稿で通知が来てしまった'); + + // --- ケース2: ファイル付き投稿 → 通知が来ること --- + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg'); + + const fileNoteRes = await api('notes/create', { + fileIds: [file.id], + }, bob); + assert.strictEqual(fileNoteRes.status, 200); + assert.deepStrictEqual(fileNoteRes.body.createdNote.fileIds, [file.id]); + + // redisに追加されるのを待つ + await setTimeout(100); + + const res = await api('i/notifications', {}, alice); + assert.strictEqual(res.status, 200); + + const noteNotif = res.body.filter((n: { type: string; note?: { id: string } }) => + n.type === 'note' && n.note?.id === fileNoteRes.body.createdNote.id, + ); + + assert.strictEqual(noteNotif.length, 1, 'ファイル付き投稿の通知が届かなかった'); + + // 後片付け + await api('following/update', { userId: bob.id, notify: 'none' }, alice); + await api('notifications/mark-all-as-read', {}, alice); + }); + test('limit パラメータが効く', async () => { // limit テスト用に bob を再度ONにして2件状態を作る await api('following/update', { userId: bob.id, notify: 'normal' }, alice); @@ -136,8 +215,34 @@ describe('following/list', () => { // limit:1 で1件に絞られることを確認 const res = await api('following/list', { notification: true, limit: 1 }, alice); + }); + + test('untilId パラメータが効く', async () => { + const allRes = await api('following/list', { notification: true }, alice); + assert.strictEqual(allRes.status, 200); + assert.strictEqual(allRes.body.length, 2); + + const newerId = allRes.body[0].id; + const olderId = allRes.body[1].id; + + const res = await api('following/list', { notification: true, untilId: newerId }, alice); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].id, olderId); + }); + + test('sinceId パラメータが効く', async () => { + const allRes = await api('following/list', { notification: true }, alice); + assert.strictEqual(allRes.status, 200); + assert.strictEqual(allRes.body.length, 2); + + const newerId = allRes.body[0].id; + const olderId = allRes.body[1].id; + + const res = await api('following/list', { notification: true, sinceId: olderId }, alice); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].id, newerId); }); test('未認証の場合はエラー', async () => { diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index e84bddf5b0d..8c30d230e29 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -111,18 +111,19 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; const $i = ensureSignin(); async function showNotifyMenu(user: Misskey.entities.UserDetailed, ev: PointerEvent) { - os.popupMenu([{ - text: (user.notify === 'normal') ? i18n.ts.unnotifyNotes : i18n.ts.notifyNotes, - icon: (user.notify === 'normal') ? 'ti ti-x' : 'ti ti-plus', - action: async () => { - await os.apiWithDialog('following/update', { + os.popupMenu((['normal', 'withFile', 'none'] as const).map(v => ({ + type: 'radioOption', + text: v === 'normal' ? i18n.ts.notifyAllNotes : v === 'withFile' ? i18n.ts.notifyNotesWithFiles : i18n.ts.unnotifyNotes, + active: computed(() => user.notify === v), + action: () => { + os.apiWithDialog('following/update', { userId: user.id, - notify: user.notify === 'normal' ? 'none' : 'normal', + notify: v, }).then(() => { - user.notify = user.notify === 'normal' ? 'none' : 'normal'; + user.notify = v; }); }, - }], ev.currentTarget ?? ev.target); + })), ev.currentTarget ?? ev.target); } const notifyUserPaginator = markRaw(new Paginator('following/list', { diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index 9b2c53360cc..8546e111077 100644 --- a/packages/frontend/src/utility/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -4,7 +4,7 @@ */ import { toUnicode } from 'punycode.js'; -import { defineAsyncComponent, ref, watch } from 'vue'; +import { defineAsyncComponent, ref, watch, computed } from 'vue'; import * as Misskey from 'misskey-js'; import { host, url } from '@@/js/config.js'; import type { Router } from '@/router.js'; @@ -85,15 +85,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router }); } - async function toggleNotify() { - os.apiWithDialog('following/update', { - userId: user.id, - notify: user.notify === 'normal' ? 'none' : 'normal', - }).then(() => { - user.notify = user.notify === 'normal' ? 'none' : 'normal'; - }); - } - async function reportAbuse() { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), { user: user, @@ -365,9 +356,24 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router text: i18n.ts.showRepliesToOthersInTimeline, ref: withRepliesRef, }, { - icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off', - text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes, - action: toggleNotify, + icon: 'ti ti-bell', + text: i18n.ts.notifyNotes, + type: 'parent', + children: async () => { + return (['normal', 'withFile', 'none'] as const).map(v => ({ + type: 'radioOption', + text: v === 'normal' ? i18n.ts.notifyAllNotes : v === 'withFile' ? i18n.ts.notifyNotesWithFiles : i18n.ts.unnotifyNotes, + active: computed(() => user.notify === v), + action: () => { + os.apiWithDialog('following/update', { + userId: user.id, + notify: v, + }).then(() => { + user.notify = v; + }); + }, + })); + }, }); watch(withRepliesRef, (withReplies) => { diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 5103f3845f8..0a4aea71f4b 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -4893,7 +4893,15 @@ export interface Locale extends ILocale { */ "notifyNotes": string; /** - * 投稿の通知を解除 + * すべての投稿を通知 + */ + "notifyAllNotes": string; + /** + * ファイル付き投稿のみ通知 + */ + "notifyNotesWithFiles": string; + /** + * 通知しない */ "unnotifyNotes": string; /** diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 0f5db3e205e..e91b4db0f7e 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4130,7 +4130,7 @@ export type components = { isMuted?: boolean; isRenoteMuted?: boolean; /** @enum {string} */ - notify?: 'normal' | 'none'; + notify?: 'normal' | 'withFile' | 'none'; withReplies?: boolean; }; MeDetailedOnly: { @@ -23248,7 +23248,7 @@ export interface operations { /** Format: misskey:id */ userId: string; /** @enum {string} */ - notify?: 'normal' | 'none'; + notify?: 'normal' | 'withFile' | 'none'; withReplies?: boolean; }; }; @@ -23324,7 +23324,7 @@ export interface operations { content: { 'application/json': { /** @enum {string} */ - notify?: 'normal' | 'none'; + notify?: 'normal' | 'withFile' | 'none'; withReplies?: boolean; }; };