diff --git a/locales/en-US.yml b/locales/en-US.yml index 7f5239ae8..6a8227511 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1598,6 +1598,7 @@ _role: high: "High" _options: gtlAvailable: "Can view the global timeline" + btlAvailable: "Can view the bubble timeline" ltlAvailable: "Can view the local timeline" canPublicNote: "Can send public notes" canImportNotes: "Can import notes" diff --git a/locales/index.d.ts b/locales/index.d.ts index 9aeaeb9e5..98ab44dd4 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1698,6 +1698,7 @@ export interface Locale { }; "_options": { "gtlAvailable": string; + "btlAvailable": string; "ltlAvailable": string; "canPublicNote": string; "canImportNotes": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2f81a25cb..0af37e041 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1607,6 +1607,7 @@ _role: high: "高" _options: gtlAvailable: "グローバルタイムラインの閲覧" + btlAvailable: "バブルのタイムラインを見ることができる" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" canImportNotes: "ノートのインポートが可能" diff --git a/packages/backend/migration/1701647674000-BubbleInstances.js b/packages/backend/migration/1701647674000-BubbleInstances.js new file mode 100644 index 000000000..9928b4c36 --- /dev/null +++ b/packages/backend/migration/1701647674000-BubbleInstances.js @@ -0,0 +1,11 @@ +export class BubbleInstances1701647674000 { + name = 'BubbleInstances1701647674000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "bubbleInstances" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "bubbleInstances"`); + } +} diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 4c5f88335..79c1ecc76 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 97bec444d..b70828f3d 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 0b6a7dfe2..e308b5d3e 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 fc5eece01..52070b515 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 e7014c133..203785679 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 0d32b7990..bf299d6ef 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 b1ba1633c..f10accaea 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 e1a1f3acb..47deeffe0 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 000000000..0652c82a9 --- /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 8fd106c10..f9f2f15af 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 000000000..74d5c3ea4 --- /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, + ); + } +} diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 466c53425..85096dc58 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -117,6 +117,12 @@ function connectChannel() { withFiles: props.onlyFiles ? true : undefined, withBots: props.withBots, }); + } else if (props.src === 'bubble') { + connection = stream.useChannel('bubbleTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withBots: props.withBots, + }); } else if (props.src === 'global') { connection = stream.useChannel('globalTimeline', { withRenotes: props.withRenotes, @@ -188,6 +194,13 @@ function updatePaginationQuery() { withFiles: props.onlyFiles ? true : undefined, withBots: props.withBots, }; + } else if (props.src === 'bubble') { + endpoint = 'notes/bubble-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withBots: props.withBots, + }; } 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 03be17de3..2f8d57c7f 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -111,6 +111,7 @@ export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', + 'btlAvailable', 'canPublicNote', 'canImportNotes', 'canInvite', diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index fff462976..cacb3254a 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -34,6 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + @@ -76,8 +81,10 @@ import FormLink from '@/components/form/link.vue'; let enableRegistration: boolean = $ref(false); let emailRequiredForSignup: boolean = $ref(false); let approvalRequiredForSignup: boolean = $ref(false); +let bubbleTimelineEnabled: boolean = $ref(false); let sensitiveWords: string = $ref(''); let preservedUsernames: string = $ref(''); +let bubbleTimeline: string = $ref(''); let tosUrl: string | null = $ref(null); let privacyPolicyUrl: string | null = $ref(null); @@ -90,6 +97,8 @@ async function init() { preservedUsernames = meta.preservedUsernames.join('\n'); tosUrl = meta.tosUrl; privacyPolicyUrl = meta.privacyPolicyUrl; + bubbleTimeline = meta.bubbleInstances.join('\n'); + bubbleTimelineEnabled = meta.policies.btlAvailable; } function save() { @@ -101,6 +110,7 @@ function save() { privacyPolicyUrl, sensitiveWords: sensitiveWords.split('\n'), preservedUsernames: preservedUsernames.split('\n'), + bubbleInstances: bubbleTimeline.split('\n'), }).then(() => { fetchInstance(); }); diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 8c656e917..efdf1ff4f 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -120,6 +120,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+ -
+

{{ i18n.ts._disabledTimeline.title }} @@ -52,6 +53,7 @@ let timeline = $shallowRef>(); 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 isBubbleTimelineAvailable = ($i == null && instance.policies.btlAvailable) || ($i != null && $i.policies.btlAvailable); const withRenotes = $ref(props.column.withRenotes ?? true); const withReplies = $ref(props.column.withReplies ?? false); const onlyFiles = $ref(props.column.onlyFiles ?? false); @@ -80,7 +82,8 @@ onMounted(() => { } else if ($i) { disabled = ( (!((instance.policies.ltlAvailable) || ($i.policies.ltlAvailable)) && ['local', 'social'].includes(props.column.tl)) || - (!((instance.policies.gtlAvailable) || ($i.policies.gtlAvailable)) && ['global'].includes(props.column.tl))); + (!((instance.policies.gtlAvailable) || ($i.policies.gtlAvailable)) && ['global'].includes(props.column.tl)) || + (!((instance.policies.btlAvailable) || ($i.policies.btlAvailable)) && ['bubble'].includes(props.column.tl))); } }); @@ -93,6 +96,8 @@ async function setType() { value: 'local' as const, text: i18n.ts._timelines.local, }, { value: 'social' as const, text: i18n.ts._timelines.social, + }, { + value: 'bubble' as const, text: 'Bubble', }, { 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 52b706820..0ebffa105 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only + @@ -20,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only -

+

{{ i18n.ts._disabledTimeline.title }} @@ -47,6 +48,7 @@ import { instance } from '@/instance.js'; 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 isBubbleTimelineAvailable = (($i == null && instance.policies.btlAvailable) || ($i != null && $i.policies.btlAvailable)); const widgetPropsDef = { showHeader: { @@ -126,6 +128,10 @@ const choose = async (ev) => { text: i18n.ts._timelines.social, icon: 'ph-rocket-launch ph-bold ph-lg', action: () => { setSrc('social'); }, + }, { + text: 'Bubble', + icon: 'ph-drop ph-bold ph-lg', + action: () => { setSrc('bubble'); }, }, { text: i18n.ts._timelines.global, icon: 'ph-globe-hemisphere-west ph-bold ph-lg', diff --git a/packages/megalodon/src/misskey/entities/meta.ts b/packages/megalodon/src/misskey/entities/meta.ts index b9b74b91d..73a0104bd 100644 --- a/packages/megalodon/src/misskey/entities/meta.ts +++ b/packages/megalodon/src/misskey/entities/meta.ts @@ -16,6 +16,7 @@ namespace MisskeyEntity { emojis: Array policies: { gtlAvailable: boolean + btlAvailable: boolean ltlAvailable: boolean canPublicNote: boolean canImportNotes: boolean diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 10b9dd5eb..05960a571 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -361,6 +361,7 @@ export type LiteInstanceMetadata = { privacyPolicyUrl: string | null; disableRegistration: boolean; disableLocalTimeline: boolean; + disableBubbleTimeline: boolean; disableGlobalTimeline: boolean; driveCapacityPerLocalUserMb: number; driveCapacityPerRemoteUserMb: number; diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts index 124770bf1..a6688e386 100644 --- a/packages/misskey-js/src/streaming.types.ts +++ b/packages/misskey-js/src/streaming.types.ts @@ -70,6 +70,13 @@ export type Channels = { }; receives: null; }; + bubbleTimeline: { + params: null; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; messaging: { params: { otherparty?: User['id'] | null;