diff --git a/.config/docker_example.yml b/.config/docker_example.yml index acd169bf43..afef8f650b 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -106,7 +106,7 @@ redis: # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── -# You can set scope to local (default value) or global +# You can set scope to local (default value) or global # (include notes from remote). #meilisearch: @@ -185,7 +185,7 @@ proxyRemoteFiles: true signToActivityPubGet: true # For security reasons, uploading attachments from the intranet is prohibited, -# but exceptions can be made from the following settings. Default value is "undefined". +# but exceptions can be made from the following settings. Default value is "undefined". # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). #allowedPrivateNetworks: [ # '127.0.0.1/32' @@ -193,3 +193,6 @@ signToActivityPubGet: true # Upload or download file size limits (bytes) #maxFileSize: 262144000 + +tagging: + defaultTag: null diff --git a/.config/example.yml b/.config/example.yml index df423c2c83..d2f1660c77 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -118,7 +118,7 @@ redis: # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── -# You can set scope to local (default value) or global +# You can set scope to local (default value) or global # (include notes from remote). #meilisearch: @@ -214,7 +214,7 @@ proxyRemoteFiles: true signToActivityPubGet: true # For security reasons, uploading attachments from the intranet is prohibited, -# but exceptions can be made from the following settings. Default value is "undefined". +# but exceptions can be made from the following settings. Default value is "undefined". # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). #allowedPrivateNetworks: [ # '127.0.0.1/32' @@ -225,3 +225,6 @@ signToActivityPubGet: true # PID File of master process #pidFile: /tmp/misskey.pid + +tagging: + defaultTag: null diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index b25554b229..281b0892e4 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -56,6 +56,9 @@ type Source = { index: string; scope?: 'local' | 'global' | string[]; }; + tagging: { + defaultTag: string; + }; proxy?: string; proxySmtp?: string; @@ -124,6 +127,9 @@ export type Config = { index: string; scope?: 'local' | 'global' | string[]; } | undefined; + tagging: { + defaultTag: string; + }; proxy: string | undefined; proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; @@ -261,6 +267,7 @@ export function loadConfig(): Config { perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), pidFile: config.pidFile, + tagging: config.tagging, }; } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 30f6d07118..ee30e28e1d 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -59,6 +59,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; +import { loadConfig } from '@/config.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -913,6 +914,16 @@ export class NoteCreateService implements OnApplicationShutdown { } } + // デフォルトハッシュタグ + const config = loadConfig(); + if (note.visibility === 'public' && note.tags.includes(String(config.tagging.defaultTag))) { + this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); + this.fanoutTimelineService.push('localTimeline', note.id, 1000, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); + } + } + // 自分自身以外への返信 if (isReply(note)) { this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 01adfec7d3..cc0d759075 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -20,6 +20,8 @@ import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { loadConfig } from '@/config.js'; export const meta = { tags: ['notes'], @@ -194,10 +196,16 @@ export default class extends Endpoint { // eslint- if (followees.length > 0) { const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); } else { qb.where('note.userId = :meId', { meId: me.id }); + } + + const config = loadConfig(); + let defaultTag:string | null = config.tagging.defaultTag; + if (defaultTag == null) { qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + } else { + qb.orWhere(`(note.visibility = 'public') AND ('${normalizeForSearch(defaultTag)}' = any(note.tags)`); } })) .innerJoinAndSelect('note.user', 'user') diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 3fd4dc83fb..dba9261214 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -18,6 +18,8 @@ import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { loadConfig } from '@/config.js'; export const meta = { tags: ['notes'], @@ -149,9 +151,15 @@ export default class extends Endpoint { // eslint- withFiles: boolean, withReplies: boolean, }, me: MiLocalUser | null) { + const config = loadConfig(); + let defaultTag:string | null = config.tagging.defaultTag; const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL) AND (note.channelId IS NULL)') + .andWhere( + (defaultTag == null) + ? '(note.visibility = \'public\') AND (note.userHost IS NULL) AND (note.channelId IS NULL)' + : `(note.visibility = 'public') AND ('${normalizeForSearch(defaultTag)}' = any(note.tags) AND (note.channelId IS NULL)` + ) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 957d8b6d41..29bdb8d327 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -13,6 +13,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import Channel, { type MiChannelService } from '../channel.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { loadConfig } from '@/config.js'; class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; @@ -22,6 +24,7 @@ class HybridTimelineChannel extends Channel { private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; + private defaultTag: string; constructor( private metaService: MetaService, @@ -43,6 +46,8 @@ class HybridTimelineChannel extends Channel { this.withRenotes = params.withRenotes ?? true; this.withReplies = params.withReplies ?? false; this.withFiles = params.withFiles ?? false; + const config = loadConfig(); + this.defaultTag = config.tagging.defaultTag; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -50,6 +55,11 @@ class HybridTimelineChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { + let matched = false; + if (this.defaultTag != null) { + const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : []; + matched = noteTags.includes(normalizeForSearch(this.defaultTag)); + } const isMe = this.user!.id === note.userId; if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; @@ -61,7 +71,7 @@ class HybridTimelineChannel extends Channel { if (!( (note.channelId == null && isMe) || (note.channelId == null && Object.hasOwn(this.following, note.userId)) || - (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || + (note.channelId == null && ((note.user.host == null || matched) && note.visibility === 'public')) || (note.channelId != null && this.followingChannels.has(note.channelId)) )) return; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 888d268d56..00908b9164 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -12,6 +12,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import Channel, { type MiChannelService } from '../channel.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { loadConfig } from '@/config.js'; class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; @@ -20,6 +22,7 @@ class LocalTimelineChannel extends Channel { private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; + private defaultTag: string; constructor( private metaService: MetaService, @@ -41,6 +44,8 @@ class LocalTimelineChannel extends Channel { this.withRenotes = params.withRenotes ?? true; this.withReplies = params.withReplies ?? false; this.withFiles = params.withFiles ?? false; + const config = loadConfig(); + this.defaultTag = config.tagging.defaultTag; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -50,7 +55,12 @@ class LocalTimelineChannel extends Channel { private async onNote(note: Packed<'Note'>) { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - if (note.user.host !== null) return; + if (this.defaultTag == null) { + if (note.user.host !== null) return; + } else { + const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : []; + if (!noteTags.includes(normalizeForSearch(this.defaultTag))) return; + } if (note.visibility !== 'public') return; if (note.channelId != null) return;