From 5d56799070006923701dcdaaa61d69c00e034209 Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 12 Apr 2023 11:40:08 +0900 Subject: [PATCH] feat: role timeline Resolve #10581 --- CHANGELOG.md | 4 +- locales/ja-JP.yml | 1 + .../backend/src/core/GlobalEventService.ts | 7 ++ .../backend/src/core/NoteCreateService.ts | 2 + packages/backend/src/core/RoleService.ts | 23 ++++ packages/backend/src/server/ServerModule.ts | 2 + .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../server/api/endpoints/antennas/notes.ts | 3 +- .../server/api/endpoints/i/notifications.ts | 3 +- .../src/server/api/endpoints/roles/notes.ts | 109 ++++++++++++++++++ .../src/server/api/stream/ChannelsService.ts | 3 + .../api/stream/channels/role-timeline.ts | 75 ++++++++++++ .../backend/src/server/api/stream/types.ts | 8 ++ .../frontend/src/components/MkTimeline.vue | 10 ++ packages/frontend/src/pages/explore.vue | 2 +- packages/frontend/src/pages/role.vue | 27 ++++- packages/frontend/src/ui/deck.vue | 1 + packages/frontend/src/ui/deck/column-core.vue | 2 + packages/frontend/src/ui/deck/deck-store.ts | 1 + .../src/ui/deck/role-timeline-column.vue | 67 +++++++++++ 21 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/roles/notes.ts create mode 100644 packages/backend/src/server/api/stream/channels/role-timeline.ts create mode 100644 packages/frontend/src/ui/deck/role-timeline-column.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index b1cd253a1..bd195790f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ ## 13.x.x (unreleased) ### General -- カスタム絵文字関連の変更 +- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加 + - Deckのカラムとしても追加可能 +- カスタム絵文字関連の改善 * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 4c5bb60e0..092a4aed3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1943,6 +1943,7 @@ _deck: channel: "チャンネル" mentions: "あなた宛て" direct: "ダイレクト" + roleTimeline: "ロールタイムライン" _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 9f4de5f98..2c2687a90 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -14,11 +14,13 @@ import type { MainStreamTypes, NoteStreamTypes, UserListStreamTypes, + RoleTimelineStreamTypes, } from '@/server/api/stream/types.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; +import { Role } from '@/models'; @Injectable() export class GlobalEventService { @@ -81,6 +83,11 @@ export class GlobalEventService { this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishRoleTimelineStream(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void { + this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishNotesStream(note: Packed<'Note'>): void { this.publish('notesStream', null, note); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 32e4fe7f8..79629cb2a 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -547,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown { this.globalEventService.publishNotesStream(noteObj); + this.roleService.addNoteToRoleTimeline(noteObj); + this.webhookService.getActiveWebhooks().then(webhooks => { webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); for (const webhook of webhooks) { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 77645e3f0..2a4271aa9 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -13,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { Packed } from '@/misc/json-schema'; import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { @@ -64,6 +65,9 @@ export class RoleService implements OnApplicationShutdown { public static NotAssignedError = class extends Error {}; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @@ -398,6 +402,25 @@ export class RoleService implements OnApplicationShutdown { this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); } + @bindThis + public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise { + const roles = await this.getUserRoles(note.userId); + + const redisPipeline = this.redisClient.pipeline(); + + for (const role of roles) { + redisPipeline.xadd( + `roleTimeline:${role.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); + } + + redisPipeline.exec(); + } + @bindThis public onApplicationShutdown(signal?: string | undefined) { this.redisForSub.off('message', this.onMessage); diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 6bae0bafd..c41e80550 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -34,6 +34,7 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; +import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; @Module({ imports: [ @@ -67,6 +68,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; DriveChannelService, GlobalTimelineChannelService, HashtagChannelService, + RoleTimelineChannelService, HomeTimelineChannelService, HybridTimelineChannelService, LocalTimelineChannelService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index ca89d8285..689f90287 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js'; import * as ep___roles_list from './endpoints/roles/list.js'; import * as ep___roles_show from './endpoints/roles/show.js'; import * as ep___roles_users from './endpoints/roles/users.js'; +import * as ep___roles_notes from './endpoints/roles/notes.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; @@ -628,6 +629,7 @@ const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_r const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default }; const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default }; const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default }; +const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default }; const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; @@ -966,6 +968,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $roles_list, $roles_show, $roles_users, + $roles_notes, $requestResetPassword, $resetDb, $resetPassword, @@ -1298,6 +1301,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $roles_list, $roles_show, $roles_users, + $roles_notes, $requestResetPassword, $resetDb, $resetPassword, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index dab897117..d0fe6a57c 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js'; import * as ep___roles_list from './endpoints/roles/list.js'; import * as ep___roles_show from './endpoints/roles/show.js'; import * as ep___roles_users from './endpoints/roles/users.js'; +import * as ep___roles_notes from './endpoints/roles/notes.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; @@ -626,6 +627,7 @@ const eps = [ ['roles/list', ep___roles_list], ['roles/show', ep___roles_show], ['roles/users', ep___roles_users], + ['roles/notes', ep___roles_notes], ['request-reset-password', ep___requestResetPassword], ['reset-db', ep___resetDb], ['reset-password', ep___resetPassword], diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index f08c20ae4..df83fe5f2 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -76,11 +76,12 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchAntenna); } + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const noteIdsRes = await this.redisClient.xrevrange( `antennaTimeline:${antenna.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', '-', - 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + 'COUNT', limit); if (noteIdsRes.length === 0) { return []; diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index f27b4e86d..ba0487f22 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -91,11 +91,12 @@ export default class extends Endpoint { const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const notificationsRes = await this.redisClient.xrevrange( `notificationTimeline:${me.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', '-', - 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + 'COUNT', limit); if (notificationsRes.length === 0) { return []; diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts new file mode 100644 index 000000000..d79528593 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { NotesRepository, RolesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['role', 'notes'], + + requireCredential: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e', + }, + }, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private idService: IdService, + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const noteIdsRes = await this.redisClient.xrevrange( + `roleTimeline:${role.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', limit); + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + + const notes = await query.getMany(); + notes.sort((a, b) => a.id > b.id ? -1 : 1); + + return await this.noteEntityService.packMany(notes, me); + }); + } +} diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index f9ef8218c..c77ba6602 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js'; import { AntennaChannelService } from './channels/antenna.js'; import { DriveChannelService } from './channels/drive.js'; import { HashtagChannelService } from './channels/hashtag.js'; +import { RoleTimelineChannelService } from './channels/role-timeline.js'; @Injectable() export class ChannelsService { @@ -24,6 +25,7 @@ export class ChannelsService { private globalTimelineChannelService: GlobalTimelineChannelService, private userListChannelService: UserListChannelService, private hashtagChannelService: HashtagChannelService, + private roleTimelineChannelService: RoleTimelineChannelService, private antennaChannelService: AntennaChannelService, private channelChannelService: ChannelChannelService, private driveChannelService: DriveChannelService, @@ -43,6 +45,7 @@ export class ChannelsService { case 'globalTimeline': return this.globalTimelineChannelService; case 'userList': return this.userListChannelService; case 'hashtag': return this.hashtagChannelService; + case 'roleTimeline': return this.roleTimelineChannelService; case 'antenna': return this.antennaChannelService; case 'channel': return this.channelChannelService; case 'drive': return this.driveChannelService; diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts new file mode 100644 index 000000000..9d106c8b2 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import Channel from '../channel.js'; +import { StreamMessages } from '../types.js'; + +class RoleTimelineChannel extends Channel { + public readonly chName = 'roleTimeline'; + public static shouldShare = false; + public static requireCredential = false; + private roleId: string; + + constructor( + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: any) { + this.roleId = params.roleId as string; + + this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: StreamMessages['roleTimeline']['payload']) { + if (data.type === 'note') { + const note = data.body; + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + + this.send('note', note); + } else { + this.send(data.type, data.body); + } + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent); + } +} + +@Injectable() +export class RoleTimelineChannelService { + public readonly shouldShare = RoleTimelineChannel.shouldShare; + public readonly requireCredential = RoleTimelineChannel.requireCredential; + + constructor( + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): RoleTimelineChannel { + return new RoleTimelineChannel( + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index ed73897e7..101f6bf26 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -148,6 +148,10 @@ export interface AntennaStreamTypes { note: Note; } +export interface RoleTimelineStreamTypes { + note: Packed<'Note'>; +} + export interface AdminStreamTypes { newAbuseUserReport: { id: AbuseUserReport['id']; @@ -209,6 +213,10 @@ export type StreamMessages = { name: `userListStream:${UserList['id']}`; payload: EventUnionFromDictionary>; }; + roleTimeline: { + name: `roleTimelineStream:${Role['id']}`; + payload: EventUnionFromDictionary>; + }; antenna: { name: `antennaStream:${Antenna['id']}`; payload: EventUnionFromDictionary>; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 6741e7a18..fb0a3a4b6 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -15,6 +15,7 @@ const props = defineProps<{ list?: string; antenna?: string; channel?: string; + role?: string; sound?: boolean; }>(); @@ -121,6 +122,15 @@ if (props.src === 'antenna') { channelId: props.channel, }); connection.on('note', prepend); +} else if (props.src === 'role') { + endpoint = 'roles/notes'; + query = { + roleId: props.role, + }; + connection = stream.useChannel('roleTimeline', { + roleId: props.role, + }); + connection.on('note', prepend); } const pagination = { diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index 2131188dd..5f3728b67 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -1,7 +1,7 @@