diff --git a/CALCKEY.md b/CALCKEY.md index 15d039e1f5..061bdfe197 100644 --- a/CALCKEY.md +++ b/CALCKEY.md @@ -34,6 +34,7 @@ - Yarn 3 - Saner defaults - Star as default reaction +- Recommended Instances timeline - Rosé Pine by default (+ non-themable elements made Rosé Pine) - Better sidebar/navbar - MOTD (customizable by admins!) diff --git a/README.md b/README.md index b9f73b15f5..15e0691a89 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Misskey documentation can be found at [Misskey Hub](https://misskey-hub.net/). ## 🚚 Migrating from Misskey to Calckey -You need at least 🐢 NodeJS v16.15.0 (v18.4.0 reccomended!) and *exactly* 🧶 Yarn v3.2.1! +You need at least 🐢 NodeJS v16.15.0 (v18.4.0 recommended!) and *exactly* 🧶 Yarn v3.2.1! ### 📩 Install dependencies diff --git a/locales/en-US.yml b/locales/en-US.yml index 7a064d2a0f..9a581fdc4c 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -322,6 +322,7 @@ connectService: "Connect" disconnectService: "Disconnect" enableLocalTimeline: "Enable local timeline" enableGlobalTimeline: "Enable global timeline" +enableRecommendedTimeline: "Enable recommended timeline" disablingTimelinesInfo: "Adminstrators and Moderators will always have access to all timelines, even if they are not enabled." registration: "Register" enableRegistration: "Enable new user registration" @@ -789,6 +790,7 @@ previewNoteText: "Show preview" customCss: "Custom CSS" customCssWarn: "This setting should only be used if you know what it does. Entering improper values may cause the client to stop functioning normally." global: "Global" +recommended: "Recommended" squareAvatars: "Display squared avatars" sent: "Sent" received: "Received" @@ -906,6 +908,8 @@ customMOTDDescription: "Custom messages for the MOTD (splash screen) separated b customSplashIcons: "Custom splash screen icons (urls)" customSplashIconsDescription: "URLs for custom splash screen icons separated by line breaks to be shown randomly every time a user loads/reloads the page. Please make sure the images are on a static URL, preferably all resized to 192x192." showUpdates: "Show a popup when Calckey updates" +recommendedInstances: "Recommended instances" +recommendedInstancesDescription: "Recommended instances seperated by line breaks to appear in the recommended timeline. Do NOT add `https://`, ONLY the domain." _sensitiveMediaDetection: description: "Reduces the effort of server moderation through automatically recognizing NSFW media via Machine Learning. This will slightly increase the load on the server." @@ -1382,6 +1386,7 @@ _instanceCharts: _timelines: home: "Home" local: "Local" + recommended: "Recommended" social: "Social" global: "Global" _pages: diff --git a/packages/backend/migration/1659042130648RecommendedTimeline.js b/packages/backend/migration/1659042130648RecommendedTimeline.js new file mode 100644 index 0000000000..39c9b41636 --- /dev/null +++ b/packages/backend/migration/1659042130648RecommendedTimeline.js @@ -0,0 +1,11 @@ +export class RecommendedTimeline1659042130648 { + name = 'RecommendedTimeline1659042130648' + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "disableRecommendedTimeline" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "meta" ADD "recommendedInstances" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`); + } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "disableRecommendedTimeline"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "recommendedInstances"`); + } + } diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index 475f68324a..63a4193aaa 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/meta.ts @@ -47,6 +47,11 @@ export class Meta { }) public disableLocalTimeline: boolean; + @Column('boolean', { + default: true, + }) + public disableRecommendedTimeline: boolean; + @Column('boolean', { default: false, }) @@ -67,6 +72,11 @@ export class Meta { }) public pinnedUsers: string[]; + @Column('varchar', { + length: 256, array: true, default: '{}', + }) + public recommendedInstances: string[]; + @Column('varchar', { length: 256, array: true, default: '{}', }) diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index c8f64ee466..51ecdd9020 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -267,6 +267,7 @@ import * as ep___pages_show from './endpoints/pages/show.js'; import * as ep___pages_unlike from './endpoints/pages/unlike.js'; import * as ep___pages_update from './endpoints/pages/update.js'; import * as ep___ping from './endpoints/ping.js'; +import * as ep___recommendedInstances from './endpoints/recommended-instances.js'; import * as ep___pinnedUsers from './endpoints/pinned-users.js'; import * as ep___customMOTD from './endpoints/custom-motd.js'; import * as ep___customSplashIcons from './endpoints/custom-splash-icons.js'; @@ -587,6 +588,7 @@ const eps = [ ['pages/update', ep___pages_update], ['ping', ep___ping], ['pinned-users', ep___pinnedUsers], + ['recommended-instances', ep___recommendedInstances], ['custom-motd', ep___customMOTD], ['custom-motd', ep___customSplashIcons], ['promo/read', ep___promo_read], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 32441a3352..71a217d935 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -163,6 +163,14 @@ export const meta = { type: 'boolean', optional: true, nullable: false, }, + recommendedInstances: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, pinnedUsers: { type: 'array', optional: true, nullable: false, @@ -388,6 +396,7 @@ export default define(meta, paramDef, async (ps, me) => { feedbackUrl: instance.feedbackUrl, disableRegistration: instance.disableRegistration, disableLocalTimeline: instance.disableLocalTimeline, + disableRecommendedTimeline: instance.disableRecommendedTimeline, disableGlobalTimeline: instance.disableGlobalTimeline, driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, @@ -417,6 +426,7 @@ export default define(meta, paramDef, async (ps, me) => { pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, useStarForReactionFallback: instance.useStarForReactionFallback, + recommendedInstances: instance.recommendedInstances, pinnedUsers: instance.pinnedUsers, customMOTD: instance.customMOTD, customSplashIcons: instance.customSplashIcons, 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 f8077a0336..0220c4a7ca 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -16,8 +16,12 @@ export const paramDef = { properties: { disableRegistration: { type: 'boolean', nullable: true }, disableLocalTimeline: { type: 'boolean', nullable: true }, + disableRecommendedTimeline: { type: 'boolean', nullable: true }, disableGlobalTimeline: { type: 'boolean', nullable: true }, useStarForReactionFallback: { type: 'boolean', nullable: true }, + recommendedInstances: { type: 'array', nullable: true, items: { + type: 'string', + } }, pinnedUsers: { type: 'array', nullable: true, items: { type: 'string', } }, @@ -129,6 +133,10 @@ export default define(meta, paramDef, async (ps, me) => { set.disableLocalTimeline = ps.disableLocalTimeline; } + if (typeof ps.disableRecommendedTimeline === 'boolean') { + set.disableRecommendedTimeline = ps.disableRecommendedTimeline; + } + if (typeof ps.disableGlobalTimeline === 'boolean') { set.disableGlobalTimeline = ps.disableGlobalTimeline; } @@ -149,6 +157,10 @@ export default define(meta, paramDef, async (ps, me) => { set.customSplashIcons = ps.customSplashIcons.filter(Boolean); } + if (Array.isArray(ps.recommendedInstances)) { + set.recommendedInstances = ps.recommendedInstances.filter(Boolean); + } + if (Array.isArray(ps.hiddenTags)) { set.hiddenTags = ps.hiddenTags.filter(Boolean); } diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index d93d399e48..e04a65abb0 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -80,6 +80,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + disableRecommendedTimeline: { + type: 'boolean', + optional: false, nullable: false, + }, disableGlobalTimeline: { type: 'boolean', optional: false, nullable: false, @@ -248,6 +252,11 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + recommended +TimeLine: { + type: 'boolean', + optional: false, nullable: false, + }, globalTimeLine: { type: 'boolean', optional: false, nullable: false, @@ -356,6 +365,7 @@ export default define(meta, paramDef, async (ps, me) => { disableRegistration: instance.disableRegistration, disableLocalTimeline: instance.disableLocalTimeline, + disableRecommendedTimeline: instance.disableRecommendedTimeline, disableGlobalTimeline: instance.disableGlobalTimeline, driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, @@ -412,6 +422,8 @@ export default define(meta, paramDef, async (ps, me) => { response.features = { registration: !instance.disableRegistration, localTimeLine: !instance.disableLocalTimeline, + recommended +Timeline: !instance.disableRecommendedTimeline, globalTimeLine: !instance.disableGlobalTimeline, emailRequiredForSignup: instance.emailRequiredForSignup, elasticsearch: config.elasticsearch ? true : false, diff --git a/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts new file mode 100644 index 0000000000..cb2a4a7253 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts @@ -0,0 +1,120 @@ +import { Brackets } from 'typeorm'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Notes, Users } from '@/models/index.js'; +import { activeUsersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; +import { ApiError } from '../../error.js'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; +import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; +import { generateRepliesQuery } from '../../common/generate-replies-query.js'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; +import { generateChannelQuery } from '../../common/generate-channel-query.js'; +import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; + +export const meta = { + tags: ['notes'], + requireCredentialPrivateMode: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, + + errors: { + ltlDisabled: { + message: 'Recommended timeline has been disabled.', + code: 'LTL_DISABLED', + id: '45a6eb02-7695-4393-b023-dd3be9aaaefe', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + withFiles: { + type: 'boolean', + default: false, + description: 'Only show notes that have attached files.', + }, + fileType: { type: 'array', items: { + type: 'string', + } }, + excludeNsfw: { type: 'boolean', default: false }, + 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; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableLocalTimeline) { + if (user == null || (!user.isAdmin && !user.isModerator)) { + throw new ApiError(meta.errors.ltlDisabled); + } + } + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('(note.visibility = \'public\') AND (note.userHost = ANY(meta.recommendedInstances))') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('user.avatar', 'avatar') + .leftJoinAndSelect('user.banner', 'banner') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') + .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') + .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateMutedNoteQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.read(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/reccomended-instances.ts b/packages/backend/src/server/api/endpoints/reccomended-instances.ts new file mode 100644 index 0000000000..844177b1d1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reccomended-instances.ts @@ -0,0 +1,32 @@ +// import { IsNull } from 'typeorm'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import define from '../define.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const meta = await fetchMeta(); + const instances = await Promise.all(meta.recommendedInstances.map(x => x)); + return instances; +}); diff --git a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts new file mode 100644 index 0000000000..03fc33b3a8 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts @@ -0,0 +1,61 @@ +import Channel from '../channel.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { checkWordMute } from '@/misc/check-word-mute.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { Packed } from '@/misc/schema.js'; + +export default class extends Channel { + public readonly chName = 'recommendedTimeline'; + public static shouldShare = true; + public static requireCredential = false; + + constructor(id: string, connection: Channel['connection']) { + super(id, connection); + this.onNote = this.withPackedNote(this.onNote.bind(this)); + } + + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableRecommendedTimeline) { + if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; + } + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + private async onNote(note: Packed<'Note'>) { + const meta = await fetchMeta(); + if (note.user.host !== null && !meta.recommendedInstances.includes(note.user.host)) return; + if (note.visibility !== 'public') return; + if (note.channelId != null && !this.followingChannels.has(note.channelId)) return; + + // 関係ない返信は除外 + if (note.reply && !this.user!.showTimelineReplies) { + 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.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) 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); + } + + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts index 1a7a20d860..b4216d9d92 100644 --- a/packages/backend/src/server/nodeinfo.ts +++ b/packages/backend/src/server/nodeinfo.ts @@ -67,6 +67,7 @@ const nodeinfo2 = async () => { feedbackUrl: meta.feedbackUrl, disableRegistration: meta.disableRegistration, disableLocalTimeline: meta.disableLocalTimeline, + disableRecommendedTimeline: meta.disableRecommendedTimeline, disableGlobalTimeline: meta.disableGlobalTimeline, emailRequiredForSignup: meta.emailRequiredForSignup, enableHcaptcha: meta.enableHcaptcha, diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue index 2060328a28..9a32d90737 100644 --- a/packages/client/src/pages/admin/settings.vue +++ b/packages/client/src/pages/admin/settings.vue @@ -57,9 +57,15 @@ {{ i18n.ts.enableLocalTimeline }} {{ i18n.ts.enableGlobalTimeline }} + {{ i18n.ts.enableRecommendedTimeline }} {{ i18n.ts.disablingTimelinesInfo }} + + + + + @@ -185,8 +191,10 @@ let defaultLightTheme: any = $ref(null); let defaultDarkTheme: any = $ref(null); let enableLocalTimeline: boolean = $ref(false); let enableGlobalTimeline: boolean = $ref(false); +let enableRecommendedTimeline: boolean = $ref(false); let pinnedUsers: string = $ref(''); let customMOTD: string = $ref(''); +let recommendedInstances: string = $ref(''); let customSplashIcons: string = $ref(''); let cacheRemoteFiles: boolean = $ref(false); let localDriveCapacityMb: any = $ref(0); @@ -214,9 +222,11 @@ async function init() { maintainerEmail = meta.maintainerEmail; enableLocalTimeline = !meta.disableLocalTimeline; enableGlobalTimeline = !meta.disableGlobalTimeline; + enableRecommendedTimeline = !meta.disableRecommendedTimeline; pinnedUsers = meta.pinnedUsers.join('\n'); customMOTD = meta.customMOTD.join('\n'); customSplashIcons = meta.customSplashIcons.join('\n'); + recommendedInstances = meta.recommendedInstances.join('\n'); cacheRemoteFiles = meta.cacheRemoteFiles; localDriveCapacityMb = meta.driveCapacityPerLocalUserMb; remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb; @@ -244,9 +254,11 @@ function save() { maintainerEmail, disableLocalTimeline: !enableLocalTimeline, disableGlobalTimeline: !enableGlobalTimeline, + disableRecommendedTimeline: !enableRecommendedTimeline, pinnedUsers: pinnedUsers.split('\n'), customMOTD: customMOTD.split('\n'), customSplashIcons: customSplashIcons.split('\n'), + recommendedInstances: recommendedInstances.split('\n'), cacheRemoteFiles, localDriveCapacityMb: parseInt(localDriveCapacityMb, 10), remoteDriveCapacityMb: parseInt(remoteDriveCapacityMb, 10), diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue index 390db967d1..cf9c7e3244 100644 --- a/packages/client/src/pages/timeline.vue +++ b/packages/client/src/pages/timeline.vue @@ -36,6 +36,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue')); const isLocalTimelineAvailable = !instance.disableLocalTimeline || ($i != null && ($i.isModerator || $i.isAdmin)); +const isRecommendedTimelineAvailable = !instance.disableRecommendedTimeline || ($i != null && ($i.isModerator || $i.isAdmin)); const isGlobalTimelineAvailable = !instance.disableGlobalTimeline || ($i != null && ($i.isModerator || $i.isAdmin)); const keymap = { 't': focus, @@ -143,6 +144,13 @@ const headerTabs = $computed(() => [{ title: i18n.ts._timelines.local, icon: 'fas fa-user-group', iconOnly: true, +}, ...(isRecommendedTimelineAvailable ? [{ + key: 'recommended +', + title: i18n.ts._timelines.recommended +, + icon: 'fas fa-comet', + iconOnly: true, }, { key: 'social', title: i18n.ts._timelines.social, @@ -172,7 +180,8 @@ const headerTabsWhenNotLogin = $computed(() => [ definePageMetadata(computed(() => ({ title: i18n.ts.timeline, - icon: src === 'local' ? 'fas fa-user-group' : src === 'social' ? 'fas fa-handshake-simple' : src === 'global' ? 'fas fa-globe' : 'fas fa-home', + icon: src === 'local' ? 'fas fa-user-group' : src === 'social' ? 'fas fa-handshake-simple' : src === 'recommended +' ? 'fas fa-comet' : src === 'global' ? 'fas fa-globe' : 'fas fa-home', }))); diff --git a/packages/client/src/ui/deck/tl-column.vue b/packages/client/src/ui/deck/tl-column.vue index 6ea8e1a8aa..16ec3d147a 100644 --- a/packages/client/src/ui/deck/tl-column.vue +++ b/packages/client/src/ui/deck/tl-column.vue @@ -49,6 +49,7 @@ onMounted(() => { } else if ($i) { disabled = !$i.isModerator && !$i.isAdmin && ( instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) || + instance.disableRecommendedTimeline && ['recommended'].includes(props.column.tl) || instance.disableGlobalTimeline && ['global'].includes(props.column.tl)); } }); @@ -60,6 +61,8 @@ async function setType() { value: 'home' as const, text: i18n.ts._timelines.home, }, { value: 'local' as const, text: i18n.ts._timelines.local, + }, { + value: 'recommended' as const, text: i18n.ts._timelines.recommended, }, { value: 'social' as const, text: i18n.ts._timelines.social, }, { diff --git a/packages/client/src/ui/visitor/b.vue b/packages/client/src/ui/visitor/b.vue index 27f6c0f5b8..bb0c59e4ff 100644 --- a/packages/client/src/ui/visitor/b.vue +++ b/packages/client/src/ui/visitor/b.vue @@ -77,7 +77,7 @@ const announcements = { endpoint: 'announcements', limit: 10, }; -const isTimelineAvailable = !instance.disableLocalTimeline || !instance.disableGlobalTimeline; +const isTimelineAvailable = !instance.disableLocalTimeline || !instance.disableRecommendedTimeline || !instance.disableGlobalTimeline; let showMenu = $ref(false); let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); let narrow = $ref(window.innerWidth < 1280); diff --git a/packages/client/src/ui/visitor/header.vue b/packages/client/src/ui/visitor/header.vue index 50d295a293..6b4dd1ded2 100644 --- a/packages/client/src/ui/visitor/header.vue +++ b/packages/client/src/ui/visitor/header.vue @@ -60,7 +60,7 @@ export default defineComponent({ return { narrow: null, showMenu: false, - isTimelineAvailable: !instance.disableLocalTimeline || !instance.disableGlobalTimeline, + isTimelineAvailable: !instance.disableLocalTimeline || !instance.disableRecommendedTimeline || !instance.disableGlobalTimeline, }; },