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:
@@ -1217,6 +1217,7 @@ keepScreenOn: "デバイスの画面を常にオンにする"
|
||||
verifiedLink: "このリンク先の所有者であることが確認されました"
|
||||
notifyNotes: "投稿を通知"
|
||||
unnotifyNotes: "投稿の通知を解除"
|
||||
notifyUsers: "投稿通知を設定したユーザー"
|
||||
authentication: "認証"
|
||||
authenticationRequiredToContinue: "続けるには認証を行ってください"
|
||||
dateAndTime: "日時"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
}
|
||||
131
packages/backend/test/e2e/note-notify.ts
Normal file
131
packages/backend/test/e2e/note-notify.ts
Normal 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('通知設定を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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -4880,6 +4880,10 @@ export interface Locale extends ILocale {
|
||||
* 投稿の通知を解除
|
||||
*/
|
||||
"unnotifyNotes": string;
|
||||
/**
|
||||
* 投稿通知を設定したユーザー
|
||||
*/
|
||||
"notifyUsers": string;
|
||||
/**
|
||||
* 認証
|
||||
*/
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user