From 2f99c7e9dc2e5e3ca06c9672a6ab4887eb094310 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Mon, 4 Dec 2023 02:10:51 +0100 Subject: add: Bubble timeline Closes transfem-org/Sharkey#154 --- packages/backend/src/core/RoleService.ts | 3 + packages/backend/src/models/Meta.ts | 5 + .../backend/src/server/NodeinfoServerService.ts | 1 + packages/backend/src/server/ServerModule.ts | 2 + packages/backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../backend/src/server/api/endpoints/admin/meta.ts | 8 ++ .../src/server/api/endpoints/admin/update-meta.ts | 5 + .../server/api/endpoints/notes/bubble-timeline.ts | 130 +++++++++++++++++++++ .../src/server/api/stream/ChannelsService.ts | 3 + .../server/api/stream/channels/bubble-timeline.ts | 124 ++++++++++++++++++++ 11 files changed, 287 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts create mode 100644 packages/backend/src/server/api/stream/channels/bubble-timeline.ts (limited to 'packages/backend/src') diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 4c5f883351..79c1ecc76f 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -26,6 +26,7 @@ import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; + btlAvailable: boolean; canPublicNote: boolean; canInvite: boolean; inviteLimit: number; @@ -53,6 +54,7 @@ export type RolePolicies = { export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, + btlAvailable: false, canPublicNote: true, canInvite: false, inviteLimit: 0, @@ -303,6 +305,7 @@ export class RoleService implements OnApplicationShutdown { return { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), + btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 97bec444d6..b70828f3dd 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -544,4 +544,9 @@ export class MiMeta { nullable: true, }) public defaultLike: string | null; + + @Column('varchar', { + length: 256, array: true, default: '{}', + }) + public bubbleInstances: string[]; } diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 0b6a7dfe22..e308b5d3e4 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -109,6 +109,7 @@ export class NodeinfoServerService { disableRegistration: meta.disableRegistration, disableLocalTimeline: !basePolicies.ltlAvailable, disableGlobalTimeline: !basePolicies.gtlAvailable, + disableBubbleTimeline: !basePolicies.btlAvailable, emailRequiredForSignup: meta.emailRequiredForSignup, enableHcaptcha: meta.enableHcaptcha, enableRecaptcha: meta.enableRecaptcha, diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index fc5eece01f..52070b5157 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -32,6 +32,7 @@ import { AntennaChannelService } from './api/stream/channels/antenna.js'; import { ChannelChannelService } from './api/stream/channels/channel.js'; import { DriveChannelService } from './api/stream/channels/drive.js'; import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; +import { BubbleTimelineChannelService } from './api/stream/channels/bubble-timeline.js'; import { HashtagChannelService } from './api/stream/channels/hashtag.js'; import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; @@ -77,6 +78,7 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; ChannelChannelService, DriveChannelService, GlobalTimelineChannelService, + BubbleTimelineChannelService, HashtagChannelService, RoleTimelineChannelService, HomeTimelineChannelService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index e7014c1333..2037856797 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -277,6 +277,7 @@ import * as ep___notes_favorites_create from './endpoints/notes/favorites/create import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; +import * as ep___notes_bubbleTimeline from './endpoints/notes/bubble-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_mentions from './endpoints/notes/mentions.js'; @@ -648,6 +649,7 @@ const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create' const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.default }; +const $notes_bubbleTimeline: Provider = { provide: 'ep:notes/bubble-timeline', useClass: ep___notes_bubbleTimeline.default }; 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_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default }; @@ -1023,6 +1025,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de $notes_favorites_delete, $notes_featured, $notes_globalTimeline, + $notes_bubbleTimeline, $notes_hybridTimeline, $notes_localTimeline, $notes_mentions, @@ -1392,6 +1395,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de $notes_favorites_delete, $notes_featured, $notes_globalTimeline, + $notes_bubbleTimeline, $notes_hybridTimeline, $notes_localTimeline, $notes_mentions, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 0d32b79900..bf299d6ef4 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -277,6 +277,7 @@ import * as ep___notes_favorites_create from './endpoints/notes/favorites/create import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; +import * as ep___notes_bubbleTimeline from './endpoints/notes/bubble-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_mentions from './endpoints/notes/mentions.js'; @@ -646,6 +647,7 @@ const eps = [ ['notes/favorites/delete', ep___notes_favorites_delete], ['notes/featured', ep___notes_featured], ['notes/global-timeline', ep___notes_globalTimeline], + ['notes/bubble-timeline', ep___notes_bubbleTimeline], ['notes/hybrid-timeline', ep___notes_hybridTimeline], ['notes/local-timeline', ep___notes_localTimeline], ['notes/mentions', ep___notes_mentions], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index b1ba1633c9..f10accaeac 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -154,6 +154,13 @@ export const meta = { type: 'string', }, }, + bubbleInstances: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, hcaptchaSecretKey: { type: 'string', optional: false, nullable: true, @@ -402,6 +409,7 @@ export default class extends Endpoint { // eslint- silencedHosts: instance.silencedHosts, sensitiveWords: instance.sensitiveWords, preservedUsernames: instance.preservedUsernames, + bubbleInstances: instance.bubbleInstances, hcaptchaSecretKey: instance.hcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, turnstileSecretKey: instance.turnstileSecretKey, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index e1a1f3acb3..47deeffe0c 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -123,6 +123,7 @@ export const paramDef = { enableIdenticonGeneration: { type: 'boolean' }, serverRules: { type: 'array', items: { type: 'string' } }, preservedUsernames: { type: 'array', items: { type: 'string' } }, + bubbleInstances: { type: 'array', items: { type: 'string' } }, manifestJsonOverride: { type: 'string' }, enableFanoutTimeline: { type: 'boolean' }, enableFanoutTimelineDbFallback: { type: 'boolean' }, @@ -482,6 +483,10 @@ export default class extends Endpoint { // eslint- set.preservedUsernames = ps.preservedUsernames; } + if (ps.bubbleInstances !== undefined) { + set.bubbleInstances = ps.bubbleInstances; + } + if (ps.manifestJsonOverride !== undefined) { set.manifestJsonOverride = ps.manifestJsonOverride; } diff --git a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts new file mode 100644 index 0000000000..0652c82a9d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -0,0 +1,130 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import type { NotesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../error.js'; +import { CacheService } from '@/core/CacheService.js'; +import { MetaService } from '@/core/MetaService.js'; + +export const meta = { + tags: ['notes'], + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, + + errors: { + btlDisabled: { + message: 'Bubble timeline has been disabled.', + code: 'BTL_DISABLED', + id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6c', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + withFiles: { type: 'boolean', default: false }, + withBots: { type: 'boolean', default: true }, + withRenotes: { type: 'boolean', default: true }, + 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: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private roleService: RoleService, + private activeUsersChart: ActiveUsersChart, + private cacheService: CacheService, + private metaService: MetaService, + ) { + super(meta, paramDef, async (ps, me) => { + const policies = await this.roleService.getUserPolicies(me ? me.id : null); + const instance = await this.metaService.fetch(); + if (!policies.btlAvailable) { + throw new ApiError(meta.errors.btlDisabled); + } + + const [ + followings, + ] = me ? await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), + ]) : [undefined]; + + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.visibility = \'public\'') + .andWhere('note.channelId IS NULL') + .andWhere( + `(note.userHost = ANY ('{"${instance.bubbleInstances.join('","')}"}'))`, + ) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (!ps.withBots) query.andWhere('user.isBot = FALSE'); + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.where('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.where('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + //#endregion + + let timeline = await query.limit(ps.limit).getMany(); + + timeline = timeline.filter(note => { + if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; + return true; + }); + + process.nextTick(() => { + if (me) { + 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 8fd106c10c..f9f2f15aff 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -8,6 +8,7 @@ import { bindThis } from '@/decorators.js'; import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './channels/local-timeline.js'; import { HomeTimelineChannelService } from './channels/home-timeline.js'; +import { BubbleTimelineChannelService } from './channels/bubble-timeline.js'; import { GlobalTimelineChannelService } from './channels/global-timeline.js'; import { MainChannelService } from './channels/main.js'; import { ChannelChannelService } from './channels/channel.js'; @@ -28,6 +29,7 @@ export class ChannelsService { private localTimelineChannelService: LocalTimelineChannelService, private hybridTimelineChannelService: HybridTimelineChannelService, private globalTimelineChannelService: GlobalTimelineChannelService, + private bubbleTimelineChannelService: BubbleTimelineChannelService, private userListChannelService: UserListChannelService, private hashtagChannelService: HashtagChannelService, private roleTimelineChannelService: RoleTimelineChannelService, @@ -48,6 +50,7 @@ export class ChannelsService { case 'localTimeline': return this.localTimelineChannelService; case 'hybridTimeline': return this.hybridTimelineChannelService; case 'globalTimeline': return this.globalTimelineChannelService; + case 'bubbleTimeline': return this.bubbleTimelineChannelService; case 'userList': return this.userListChannelService; case 'hashtag': return this.hashtagChannelService; case 'roleTimeline': return this.roleTimelineChannelService; diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts new file mode 100644 index 0000000000..74d5c3ea4e --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { checkWordMute } from '@/misc/check-word-mute.js'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isUserRelated } from '@/misc/is-user-related.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 type { MiMeta } from '@/models/Meta.js'; +import Channel from '../channel.js'; + +class BubbleTimelineChannel extends Channel { + public readonly chName = 'bubbleTimeline'; + public static shouldShare = false; + public static requireCredential = false; + private withRenotes: boolean; + private withFiles: boolean; + private withBots: boolean; + private instance: MiMeta; + + 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) { + const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); + if (!policies.btlAvailable) return; + + this.withRenotes = params.withRenotes ?? true; + this.withFiles = params.withFiles ?? false; + this.withBots = params.withBots ?? true; + this.instance = await this.metaService.fetch(); + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @bindThis + private async onNote(note: Packed<'Note'>) { + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; + if (!this.withBots && note.user.isBot) return; + + if (!(note.user.host != null && this.instance.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return; + + if (note.channelId != null) return; + + // 関係ない返信は除外 + if (note.reply && !this.following[note.userId]?.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; + + if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + + // Ignore notes from instances the user has muted + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) 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; + + if (this.user && note.renoteId && !note.text) { + if (note.renote && Object.keys(note.renote.reactions).length > 0) { + const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); + note.renote.myReaction = myRenoteReaction; + } + } + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} + +@Injectable() +export class BubbleTimelineChannelService { + public readonly shouldShare = BubbleTimelineChannel.shouldShare; + public readonly requireCredential = BubbleTimelineChannel.requireCredential; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): BubbleTimelineChannel { + return new BubbleTimelineChannel( + this.metaService, + this.roleService, + this.noteEntityService, + id, + connection, + ); + } +} -- cgit v1.2.3-freya