1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-16 06:15:30 +02:00

feat: 投稿通知設定したユーザーをリストで見ることができるように (#17385)

* feat: 投稿通知を設定したユーザーをリストで見ることができるように

* test(e2e): 投稿通知のテスト追加

* chore: 不必要なコードの削除
This commit is contained in:
4ster1sk
2026-05-12 21:34:45 +09:00
committed by GitHub
parent bf3c1f6686
commit 6665c398d6
11 changed files with 377 additions and 1 deletions

View File

@@ -1217,6 +1217,7 @@ keepScreenOn: "デバイスの画面を常にオンにする"
verifiedLink: "このリンク先の所有者であることが確認されました"
notifyNotes: "投稿を通知"
unnotifyNotes: "投稿の通知を解除"
notifyUsers: "投稿通知を設定したユーザー"
authentication: "認証"
authenticationRequiredToContinue: "続けるには認証を行ってください"
dateAndTime: "日時"

View File

@@ -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';

View File

@@ -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<typeof meta, typeof paramDef> { // 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' });
});
}
}

View File

@@ -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('通知設定をOFFnoneにすると一覧から外れる', 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);
});
});

View File

@@ -36,6 +36,28 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
</div>
</FormSection>
<FormSection>
<SearchMarker
:keywords="['notify', 'hide', 'user']"
>
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.notifyUsers }}</SearchLabel></template>
<MkPagination v-slot="{items}" :paginator="notifyUserPaginator" withControl>
<div class="_gaps_s">
<div v-for="item in items" :key="item.id" :class="[$style.userItem ]">
<div :class="$style.userItemMain">
<MkA :class="$style.userItemMainBody" :to="userPage(item)">
<MkUserCardMini :user="item"/>
</MkA>
<button class="_button" :class="$style.notifyMenu" @click="showNotifyMenu(item, $event)"><i class="ti ti-dots"></i></button>
</div>
</div>
</div>
</MkPagination>
</MkFolder>
</SearchMarker>
</FormSection>
<FormSection>
<div class="_gaps_m">
<FormLink to="/settings/sounds">{{ i18n.ts.notificationSoundSettings }}</FormLink>
@@ -64,8 +86,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { useTemplateRef, computed } from 'vue';
import { useTemplateRef, computed, ref, markRaw } from 'vue';
import { notificationTypes } from 'misskey-js';
import * as Misskey from 'misskey-js';
import XNotificationConfig from './notifications.notification-config.vue';
import type { NotificationConfig } from './notifications.notification-config.vue';
import FormLink from '@/components/form/link.vue';
@@ -80,9 +103,32 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { Paginator } from '@/utility/paginator.js';
import MkPagination from '@/components/MkPagination.vue';
import { userPage } from '@/filters/user.js';
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', {
userId: user.id,
notify: user.notify === 'normal' ? 'none' : 'normal',
}).then(() => {
user.notify = user.notify === 'normal' ? 'none' : 'normal';
});
},
}], ev.currentTarget ?? ev.target);
}
const notifyUserPaginator = markRaw(new Paginator('users/notify/list', {
limit: 10,
}));
const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] as const satisfies (typeof notificationTypes[number])[];
const configurableNotificationTypes = notificationTypes.filter(type => !nonConfigurableNotificationTypes.includes(type as any)) as Exclude<typeof notificationTypes[number], typeof nonConfigurableNotificationTypes[number]>[];
@@ -145,3 +191,25 @@ definePage(() => ({
icon: 'ti ti-bell',
}));
</script>
<style lang="scss" module>
.userItemMain {
display: flex;
}
.userItemMainBody {
flex: 1;
min-width: 0;
margin-right: 8px;
&:hover {
text-decoration: none;
}
}
.notifyMenu {
width: 32px;
height: 32px;
align-self: center;
}
</style>

View File

@@ -4880,6 +4880,10 @@ export interface Locale extends ILocale {
* 投稿の通知を解除
*/
"unnotifyNotes": string;
/**
* 投稿通知を設定したユーザー
*/
"notifyUsers": string;
/**
* 認証
*/

View File

@@ -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'];

View File

@@ -4697,6 +4697,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* List of following users with notification enabled.
*
* **Credential required**: *Yes* / **Permission**: *read:following*
*/
request<E extends 'users/notify/list', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* Show all pages this user created.
*

View File

@@ -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 };

View File

@@ -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'];

View File

@@ -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: {