summaryrefslogtreecommitdiff
path: root/packages/backend/src
diff options
context:
space:
mode:
authorMar0xy <marie@kaifa.ch>2023-12-04 02:10:51 +0100
committerMar0xy <marie@kaifa.ch>2023-12-04 02:10:51 +0100
commit2f99c7e9dc2e5e3ca06c9672a6ab4887eb094310 (patch)
treeaa9801e261ed978d553cfc8cd80fee524d6496a6 /packages/backend/src
parentupd: add additional check to visibility selector for boost (diff)
downloadsharkey-2f99c7e9dc2e5e3ca06c9672a6ab4887eb094310.tar.gz
sharkey-2f99c7e9dc2e5e3ca06c9672a6ab4887eb094310.tar.bz2
sharkey-2f99c7e9dc2e5e3ca06c9672a6ab4887eb094310.zip
add: Bubble timeline
Closes transfem-org/Sharkey#154
Diffstat (limited to 'packages/backend/src')
-rw-r--r--packages/backend/src/core/RoleService.ts3
-rw-r--r--packages/backend/src/models/Meta.ts5
-rw-r--r--packages/backend/src/server/NodeinfoServerService.ts1
-rw-r--r--packages/backend/src/server/ServerModule.ts2
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts4
-rw-r--r--packages/backend/src/server/api/endpoints.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts8
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts130
-rw-r--r--packages/backend/src/server/api/stream/ChannelsService.ts3
-rw-r--r--packages/backend/src/server/api/stream/channels/bubble-timeline.ts124
11 files changed, 287 insertions, 0 deletions
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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<string>(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,
+ );
+ }
+}