diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b100bae66b..26ef054b9f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1217,6 +1217,7 @@ keepScreenOn: "デバイスの画面を常にオンにする" verifiedLink: "このリンク先の所有者であることが確認されました" notifyNotes: "投稿を通知" unnotifyNotes: "投稿の通知を解除" +notifyUsers: "投稿通知を設定したユーザー" authentication: "認証" authenticationRequiredToContinue: "続けるには認証を行ってください" dateAndTime: "日時" diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 6679005c3c..60a4656214 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -391,6 +391,7 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js'; export * as 'users/flashs' from './endpoints/users/flashs.js'; export * as 'users/followers' from './endpoints/users/followers.js'; export * as 'users/following' from './endpoints/users/following.js'; +export * as 'users/notify/list' from './endpoints/users/notify/list.js'; export * as 'users/get-following-users-by-birthday' from './endpoints/users/get-following-users-by-birthday.js'; export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js'; export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js'; diff --git a/packages/backend/src/server/api/endpoints/users/notify/list.ts b/packages/backend/src/server/api/endpoints/users/notify/list.ts new file mode 100644 index 0000000000..4c7ca48134 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/notify/list.ts @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { FollowingsRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['users'], + + requireCredential: true, + kind: 'read:following', + description: 'List of following users with notification enabled.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .innerJoinAndSelect('following.followee', 'followee') + .andWhere('following.followerId = :userId', { userId: me.id }) + .andWhere('following.notify IS NOT NULL'); + + const followings = await query + .limit(ps.limit) + .getMany(); + + const users = followings.map(f => f.followee!); + + return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); + }); + } +} diff --git a/packages/backend/test/e2e/note-notify.ts b/packages/backend/test/e2e/note-notify.ts new file mode 100644 index 0000000000..9835223053 --- /dev/null +++ b/packages/backend/test/e2e/note-notify.ts @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 type * as misskey from 'misskey-js'; + +describe('users/notify/list', () => { + let alice: misskey.entities.SignupResponse; + let bob: misskey.entities.SignupResponse; + let carol: misskey.entities.SignupResponse; + + beforeAll(async () => { + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }, 1000 * 60 * 2); + + test('通知設定なしのフォローのみの場合、空配列が返る', async () => { + // alice が bob を普通にフォロー(通知設定なし) + await api('following/create', { userId: bob.id }, alice); + + const res = await api('users/notify/list', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.length, 0); + }); + + test('通知設定ありのフォローがある場合、そのユーザーが返る', async () => { + // alice が carol をフォローして通知ON + await api('following/create', { userId: carol.id, withReplies: false }, alice); + await api('following/update', { userId: carol.id, notify: 'normal' }, alice); + + const res = await api('users/notify/list', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].id, carol.id); + }); + + test('複数ユーザーで通知設定ありの場合、全員返る', async () => { + // bob にも通知設定をON + await api('following/update', { userId: bob.id, notify: 'normal' }, alice); + + const res = await api('users/notify/list', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.length, 2); + + const ids = res.body.map((u: { id: string }) => u.id).sort(); + assert.deepStrictEqual(ids, [bob.id, carol.id].sort()); + }); + + test('通知設定をOFF(none)にすると一覧から外れる', async () => { + await api('following/update', { userId: bob.id, notify: 'none' }, alice); + + const res = await api('users/notify/list', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body[0].id, carol.id); + }); + + test('他のユーザーの通知対象は見えない', async () => { + // bob が carol をフォローして通知ON + await api('following/create', { userId: carol.id }, bob); + await api('following/update', { userId: carol.id, notify: 'normal' }, bob); + + // alice の一覧には bob の通知設定は反映されない + const aliceRes = await api('users/notify/list', {}, alice); + const aliceIds = aliceRes.body.map((u: { id: string }) => u.id); + assert.strictEqual(aliceIds.includes(bob.id), false); + + // bob の一覧には carol だけが含まれる + const bobRes = await api('users/notify/list', {}, bob); + assert.strictEqual(bobRes.body.length, 1); + assert.strictEqual(bobRes.body[0].id, carol.id); + + // 後片付け: bob → carol のフォローを解除 + await api('following/delete', { userId: carol.id }, bob); + }); + + test('normal通知設定時、投稿で通知が届く', async () => { + await api('following/update', { userId: bob.id, notify: 'normal' }, alice); + + await api('notifications/mark-all-as-read', {}, alice); + 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 noteNotif = beforeRes.body.filter((n: { type: string; note?: { id: string } }) => + n.type === 'note' && n.note?.id === textOnlyRes.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); + + // limitなしだと2件返ることを確認 + const allRes = await api('users/notify/list', {}, alice); + assert.strictEqual(allRes.status, 200); + assert.strictEqual(allRes.body.length, 2); + + // limit:1 で1件に絞られることを確認 + const res = await api('users/notify/list', { limit: 1 }, alice); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.length, 1); + }); + + test('未認証の場合はエラー', async () => { + const res = await api('users/notify/list', {}); + assert.strictEqual(res.status, 401); + }); +}); diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 3787e07626..ffd8493cc3 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -36,6 +36,28 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
{{ i18n.ts.notificationSoundSettings }} @@ -64,8 +86,9 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 80f62f78fc..e13458c22d 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -4880,6 +4880,10 @@ export interface Locale extends ILocale { * 投稿の通知を解除 */ "unnotifyNotes": string; + /** + * 投稿通知を設定したユーザー + */ + "notifyUsers": string; /** * 認証 */ diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 7989568e1d..852ccbb9b9 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2162,6 +2162,8 @@ declare namespace entities { UsersListsUpdateMembershipRequest, UsersNotesRequest, UsersNotesResponse, + UsersNotifyListRequest, + UsersNotifyListResponse, UsersPagesRequest, UsersPagesResponse, UsersReactionsRequest, @@ -3827,6 +3829,12 @@ type UsersNotesRequest = operations['users___notes']['requestBody']['content'][' // @public (undocumented) type UsersNotesResponse = operations['users___notes']['responses']['200']['content']['application/json']; +// @public (undocumented) +type UsersNotifyListRequest = operations['users___notify___list']['requestBody']['content']['application/json']; + +// @public (undocumented) +type UsersNotifyListResponse = operations['users___notify___list']['responses']['200']['content']['application/json']; + // @public (undocumented) type UsersPagesRequest = operations['users___pages']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 93b97e91d4..395156ad93 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -4697,6 +4697,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * List of following users with notification enabled. + * + * **Credential required**: *Yes* / **Permission**: *read:following* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * Show all pages this user created. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 2d4e0fe35e..dbbc44cb7b 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -640,6 +640,8 @@ import type { UsersListsUpdateMembershipRequest, UsersNotesRequest, UsersNotesResponse, + UsersNotifyListRequest, + UsersNotifyListResponse, UsersPagesRequest, UsersPagesResponse, UsersReactionsRequest, @@ -1084,6 +1086,7 @@ export type Endpoints = { 'users/lists/update': { req: UsersListsUpdateRequest; res: UsersListsUpdateResponse }; 'users/lists/update-membership': { req: UsersListsUpdateMembershipRequest; res: EmptyResponse }; 'users/notes': { req: UsersNotesRequest; res: UsersNotesResponse }; + 'users/notify/list': { req: UsersNotifyListRequest; res: UsersNotifyListResponse }; 'users/pages': { req: UsersPagesRequest; res: UsersPagesResponse }; 'users/reactions': { req: UsersReactionsRequest; res: UsersReactionsResponse }; 'users/recommendation': { req: UsersRecommendationRequest; res: UsersRecommendationResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index a49dd729e6..c2f845d8cc 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -643,6 +643,8 @@ export type UsersListsUpdateResponse = operations['users___lists___update']['res export type UsersListsUpdateMembershipRequest = operations['users___lists___update-membership']['requestBody']['content']['application/json']; export type UsersNotesRequest = operations['users___notes']['requestBody']['content']['application/json']; export type UsersNotesResponse = operations['users___notes']['responses']['200']['content']['application/json']; +export type UsersNotifyListRequest = operations['users___notify___list']['requestBody']['content']['application/json']; +export type UsersNotifyListResponse = operations['users___notify___list']['responses']['200']['content']['application/json']; export type UsersPagesRequest = operations['users___pages']['requestBody']['content']['application/json']; export type UsersPagesResponse = operations['users___pages']['responses']['200']['content']['application/json']; export type UsersReactionsRequest = operations['users___reactions']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 7c79424736..ecc64039d7 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3852,6 +3852,15 @@ export type paths = { */ post: operations['users___notes']; }; + '/users/notify/list': { + /** + * users/notify/list + * @description List of following users with notification enabled. + * + * **Credential required**: *Yes* / **Permission**: *read:following* + */ + post: operations['users___notify___list']; + }; '/users/pages': { /** * users/pages @@ -36047,6 +36056,78 @@ export interface operations { }; }; }; + users___notify___list: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + sinceDate?: number; + untilDate?: number; + /** @default 10 */ + limit?: number; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserDetailed'][]; + }; + }; + /** @description Client error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; users___pages: { requestBody: { content: {