From 5e02db855e89afd82d546e838ad47455569ad944 Mon Sep 17 00:00:00 2001 From: NoriDev Date: Thu, 22 Jun 2023 16:44:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=82=AD=E3=83=A3=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG_CHERRYPICK.md | 1 + locales/en-US.yml | 2 + locales/index.d.ts | 2 + locales/ja-JP.yml | 2 + locales/ko-KR.yml | 2 + packages/backend/src/core/RoleService.ts | 3 + .../src/server/NodeinfoServerService.ts | 1 + packages/backend/src/server/ServerModule.ts | 2 + .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../api/endpoints/notes/cat-timeline.ts | 150 ++++++++++++++++++ .../src/server/api/stream/ChannelsService.ts | 3 + .../api/stream/channels/cat-timeline.ts | 135 ++++++++++++++++ packages/cherrypick-js/src/streaming.types.ts | 7 + .../frontend/src/components/MkTimeline.vue | 9 ++ packages/frontend/src/const.ts | 1 + .../frontend/src/pages/admin/roles.editor.vue | 20 +++ packages/frontend/src/pages/admin/roles.vue | 8 + packages/frontend/src/pages/timeline.vue | 8 +- packages/frontend/src/ui/deck/deck-store.ts | 2 +- packages/frontend/src/ui/deck/tl-column.vue | 9 +- .../frontend/src/widgets/WidgetTimeline.vue | 8 +- 22 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/notes/cat-timeline.ts create mode 100644 packages/backend/src/server/api/stream/channels/cat-timeline.ts diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md index a70f09f162..258bb9f305 100644 --- a/CHANGELOG_CHERRYPICK.md +++ b/CHANGELOG_CHERRYPICK.md @@ -30,6 +30,7 @@ - 리액션 수신의 기본값을 전체로 설정 - 제어판 메인 화면에 서버 통계 추가 - 노트의 시간을 일자로 표시하는 기능 +- 고양이 타임라인 추가 ### Client - 리노트 전 확인 팝업을 띄움 diff --git a/locales/en-US.yml b/locales/en-US.yml index 646008b724..f3b7ed64a4 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1484,6 +1484,7 @@ _role: _options: gtlAvailable: "Can view the global timeline" ltlAvailable: "Can view the local timeline" + ctlAvailable: "Can view the cat timeline" canPublicNote: "Can send public notes" canInvite: "Can create instance invite codes" canManageCustomEmojis: "Can manage custom emojis" @@ -2053,6 +2054,7 @@ _timelines: local: "Local" media: "Media" social: "Social" + cat: "Cat" global: "Global" _play: new: "Create Play" diff --git a/locales/index.d.ts b/locales/index.d.ts index f589e817c9..d1fb3032f8 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1572,6 +1572,7 @@ export interface Locale { "_options": { "gtlAvailable": string; "ltlAvailable": string; + "ctlAvailable": string; "canPublicNote": string; "canInvite": string; "canManageCustomEmojis": string; @@ -2192,6 +2193,7 @@ export interface Locale { "local": string; "media": string; "social": string; + "cat": string; "global": string; }; "_play": { diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 600bf70eca..b5ebf534d8 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1494,6 +1494,7 @@ _role: _options: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" + ctlAvailable: "キャットタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" canInvite: "サーバー招待コードの発行" canManageCustomEmojis: "カスタム絵文字の管理" @@ -2106,6 +2107,7 @@ _timelines: local: "ローカル" media: "メディア" social: "ソーシャル" + cat: "キャット" global: "グローバル" _play: diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 7bc7ed9af0..d1e778da2d 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1484,6 +1484,7 @@ _role: _options: gtlAvailable: "글로벌 타임라인 보이기" ltlAvailable: "로컬 타임라인 보이기" + ctlAvailable: "고양이 타임라인 보이기" canPublicNote: "공개 노트 허용" canInvite: "서버 초대 코드 발행" canManageCustomEmojis: "커스텀 이모지 관리" @@ -2053,6 +2054,7 @@ _timelines: local: "로컬" media: "미디어" social: "소셜" + cat: "고양이" global: "글로벌" _play: new: "Play 만들기" diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 79922d0a87..890c4a4174 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -19,6 +19,7 @@ import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; + ctlAvailable: boolean; canPublicNote: boolean; canInvite: boolean; canManageCustomEmojis: boolean; @@ -40,6 +41,7 @@ export type RolePolicies = { export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, + ctlAvailable: true, canPublicNote: true, canInvite: false, canManageCustomEmojis: false, @@ -275,6 +277,7 @@ export class RoleService implements OnApplicationShutdown { return { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), + ctlAvailable: calc('ctlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index a13a3c7c62..8ec3162e89 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -105,6 +105,7 @@ export class NodeinfoServerService { feedbackUrl: meta.feedbackUrl, disableRegistration: meta.disableRegistration, disableLocalTimeline: !basePolicies.ltlAvailable, + disableCatTimeline: !basePolicies.ctlAvailable, disableGlobalTimeline: !basePolicies.gtlAvailable, emailRequiredForSignup: meta.emailRequiredForSignup, enableHcaptcha: meta.enableHcaptcha, diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index ce7f94d2e9..3debb54fd7 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -31,6 +31,7 @@ import { HomeTimelineChannelService } from './api/stream/channels/home-timeline. import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; import { MediaTimelineChannelService } from './api/stream/channels/media-timeline.js'; +import { CatTimelineChannelService } from './api/stream/channels/cat-timeline.js'; import { MessagingIndexChannelService } from './api/stream/channels/messaging-index.js'; import { MessagingChannelService } from './api/stream/channels/messaging.js'; import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; @@ -78,6 +79,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline. HybridTimelineChannelService, LocalTimelineChannelService, MediaTimelineChannelService, + CatTimelineChannelService, MessagingIndexChannelService, MessagingChannelService, QueueStatsChannelService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 11b8ea3818..c024536981 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -262,6 +262,7 @@ import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_mediaTimeline from './endpoints/notes/media-timeline.js'; +import * as ep___notes_catTimeline from './endpoints/notes/cat-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; @@ -626,6 +627,7 @@ const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', u const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.default }; const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', useClass: ep___notes_localTimeline.default }; const $notes_mediaTimeline: Provider = { provide: 'ep:notes/media-timeline', useClass: ep___notes_mediaTimeline.default }; +const $notes_catTimeline: Provider = { provide: 'ep:notes/cat-timeline', useClass: ep___notes_catTimeline.default }; const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default }; const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default }; const $notes_polls_vote: Provider = { provide: 'ep:notes/polls/vote', useClass: ep___notes_polls_vote.default }; @@ -994,6 +996,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_hybridTimeline, $notes_localTimeline, $notes_mediaTimeline, + $notes_catTimeline, $notes_mentions, $notes_polls_recommendation, $notes_polls_vote, @@ -1355,6 +1358,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_hybridTimeline, $notes_localTimeline, $notes_mediaTimeline, + $notes_catTimeline, $notes_mentions, $notes_polls_recommendation, $notes_polls_vote, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 36682129ad..b160eabbdb 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -262,6 +262,7 @@ import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_mediaTimeline from './endpoints/notes/media-timeline.js'; +import * as ep___notes_catTimeline from './endpoints/notes/cat-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; @@ -624,6 +625,7 @@ const eps = [ ['notes/hybrid-timeline', ep___notes_hybridTimeline], ['notes/local-timeline', ep___notes_localTimeline], ['notes/media-timeline', ep___notes_mediaTimeline], + ['notes/cat-timeline', ep___notes_catTimeline], ['notes/mentions', ep___notes_mentions], ['notes/polls/recommendation', ep___notes_polls_recommendation], ['notes/polls/vote', ep___notes_polls_vote], diff --git a/packages/backend/src/server/api/endpoints/notes/cat-timeline.ts b/packages/backend/src/server/api/endpoints/notes/cat-timeline.ts new file mode 100644 index 0000000000..b1d177f61c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/cat-timeline.ts @@ -0,0 +1,150 @@ +import { Brackets } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository, FollowingsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, + + errors: { + ctlDisabled: { + message: 'Hybrid timeline has been disabled.', + code: 'CTL_DISABLED', + id: '620763f4-f621-4533-ab33-0577a1a3c342', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + 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' }, + includeMyRenotes: { type: 'boolean', default: true }, + includeRenotedMyNotes: { type: 'boolean', default: true }, + includeLocalRenotes: { type: 'boolean', default: true }, + withFiles: { type: 'boolean', default: false }, + withReplies: { type: 'boolean', default: false }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private metaService: MetaService, + private roleService: RoleService, + private activeUsersChart: ActiveUsersChart, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const policies = await this.roleService.getUserPolicies(me.id); + if (!policies.ctlAvailable) { + throw new ApiError(meta.errors.ctlDisabled); + } + + //#region Construct query + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで + .andWhere(new Brackets(qb => { + qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }) + .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + })) + .andWhere('(select "isCat" from "user" where id = note."userId")') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(followingQuery.getParameters()); + + this.queryService.generateChannelQuery(query, me); + this.queryService.generateRepliesQuery(query, ps.withReplies, me); + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(timeline, me); + }); + } +} diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 018584bbac..29b6d06071 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -3,6 +3,7 @@ import { bindThis } from '@/decorators.js'; import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './channels/local-timeline.js'; import { MediaTimelineChannelService } from './channels/media-timeline.js'; +import { CatTimelineChannelService } from './channels/cat-timeline.js'; import { HomeTimelineChannelService } from './channels/home-timeline.js'; import { GlobalTimelineChannelService } from './channels/global-timeline.js'; import { MainChannelService } from './channels/main.js'; @@ -25,6 +26,7 @@ export class ChannelsService { private homeTimelineChannelService: HomeTimelineChannelService, private localTimelineChannelService: LocalTimelineChannelService, private mediaTimelineChannelService: MediaTimelineChannelService, + private catTimelineChannelService: CatTimelineChannelService, private hybridTimelineChannelService: HybridTimelineChannelService, private globalTimelineChannelService: GlobalTimelineChannelService, private userListChannelService: UserListChannelService, @@ -49,6 +51,7 @@ export class ChannelsService { case 'localTimeline': return this.localTimelineChannelService; case 'mediaTimeline': return this.mediaTimelineChannelService; case 'hybridTimeline': return this.hybridTimelineChannelService; + case 'catTimeline': return this.catTimelineChannelService; case 'globalTimeline': return this.globalTimelineChannelService; case 'userList': return this.userListChannelService; case 'hashtag': return this.hashtagChannelService; diff --git a/packages/backend/src/server/api/stream/channels/cat-timeline.ts b/packages/backend/src/server/api/stream/channels/cat-timeline.ts new file mode 100644 index 0000000000..1351b27fd6 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/cat-timeline.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { checkWordMute } from '@/misc/check-word-mute.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; +import Channel from '../channel.js'; + +class CatTimelineChannel extends Channel { + public readonly chName = 'catTimeline'; + public static shouldShare = true; + public static requireCredential = true; + private withReplies: boolean; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: any): Promise { + const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); + if (!policies.ctlAvailable) return; + + this.withReplies = params.withReplies as boolean; + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @bindThis + private async onNote(note: Packed<'Note'>) { + // チャンネルの投稿ではなく、自分自身の投稿 または + // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または + // チャンネルの投稿ではなく、全体公開のローカルの投稿 または + // フォローしているチャンネルの投稿 の場合だけ + if (!( + (note.channelId == null && this.user!.id === note.userId) || + (note.channelId == null && this.following.has(note.userId)) || + (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || + (note.channelId != null && this.followingChannels.has(note.channelId)) + )) return; + + if (['followers', 'specified'].includes(note.visibility)) { + note = await this.noteEntityService.pack(note.id, this.user!, { + detail: true, + }); + + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await this.noteEntityService.pack(note.replyId, this.user!, { + detail: true, + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await this.noteEntityService.pack(note.renoteId, this.user!, { + detail: true, + }); + } + } + + // Ignore notes from instances the user has muted + if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances ?? []))) return; + + // 関係ない返信は除外 + if (note.reply && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @bindThis + public dispose(): void { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} + +@Injectable() +export class CatTimelineChannelService { + public readonly shouldShare = CatTimelineChannel.shouldShare; + public readonly requireCredential = CatTimelineChannel.requireCredential; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): CatTimelineChannel { + return new CatTimelineChannel( + this.metaService, + this.roleService, + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/cherrypick-js/src/streaming.types.ts b/packages/cherrypick-js/src/streaming.types.ts index b7d928229f..3b763a16e0 100644 --- a/packages/cherrypick-js/src/streaming.types.ts +++ b/packages/cherrypick-js/src/streaming.types.ts @@ -70,6 +70,13 @@ export type Channels = { }; receives: null; }; + catTimeline: { + params: null; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; globalTimeline: { params: null; events: { diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 6fdba5fa21..f0c0a1264f 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -107,6 +107,15 @@ if (props.src === 'antenna') { withReplies: defaultStore.state.showTimelineReplies, }); connection.on('note', prepend); +} else if (props.src === 'cat') { + endpoint = 'notes/cat-timeline'; + query = { + withReplies: defaultStore.state.showTimelineReplies, + }; + connection = stream.useChannel('catTimeline', { + withReplies: defaultStore.state.showTimelineReplies, + }); + connection.on('note', prepend); } else if (props.src === 'global') { endpoint = 'notes/global-timeline'; query = { diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 1f0f7cc400..66956f56a3 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -55,6 +55,7 @@ export const obsoleteNotificationTypes = ['pollVote'/*, 'groupInvited'*/] as con export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', + 'ctlAvailable', 'canPublicNote', 'canInvite', 'canManageCustomEmojis', diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index a1fa9d2932..5ee64e3234 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -131,6 +131,26 @@ + + + +
+ + + + + + + + + +
+
+ -
+

{{ i18n.ts._disabledTimeline.title }} @@ -39,13 +40,15 @@ let disabled = $ref(false); const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); +const isCatTimelineAvailable = (($i == null && instance.policies.ctlAvailable) || ($i != null && $i.policies.ctlAvailable)); onMounted(() => { if (props.column.tl == null) { setType(); } else if ($i) { disabled = ( - (!((instance.policies.ltlAvailable) || ($i.policies.ltlAvailable)) && ['local', 'social'].includes(props.column.tl)) || + (!((instance.policies.ltlAvailable) || ($i.policies.ltlAvailable)) && ['local', 'social', 'cat'].includes(props.column.tl)) || + (!((instance.policies.ctlAvailable) || ($i.policies.ctlAvailable)) && ['cat'].includes(props.column.tl)) || (!((instance.policies.gtlAvailable) || ($i.policies.gtlAvailable)) && ['global'].includes(props.column.tl))); } }); @@ -61,6 +64,8 @@ async function setType() { value: 'media' as const, text: i18n.ts._timelines.media, }, { value: 'social' as const, text: i18n.ts._timelines.social, + }, { + value: 'cat' as const, text: i18n.ts._timelines.cat, }, { value: 'global' as const, text: i18n.ts._timelines.global, }], diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index 5e690b1258..0183da2aec 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -4,6 +4,7 @@ + @@ -15,7 +16,7 @@ -

+

{{ i18n.ts._disabledTimeline.title }} @@ -42,6 +43,7 @@ import { instance } from '@/instance'; const name = 'timeline'; const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable)); +const isCatTimelineAvailable = (($i == null && instance.policies.ctlAvailable) || ($i != null && $i.policies.ctlAvailable)); const widgetPropsDef = { showHeader: { @@ -125,6 +127,10 @@ const choose = async (ev) => { text: i18n.ts._timelines.social, icon: 'ti ti-rocket', action: () => { setSrc('social'); }, + }, { + text: i18n.ts._timelines.cat, + icon: 'ti ti-cat', + action: () => { setSrc('cat'); }, }, { text: i18n.ts._timelines.global, icon: 'ti ti-world',