diff --git a/CALCKEY.md b/CALCKEY.md
index 15d039e1f..061bdfe19 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 b9f73b15f..15e0691a8 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 7a064d2a0..9a581fdc4 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 000000000..39c9b4163
--- /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 475f68324..63a4193aa 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 c8f64ee46..51ecdd902 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 32441a335..71a217d93 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 f8077a033..0220c4a7c 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 d93d399e4..e04a65abb 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 000000000..cb2a4a725
--- /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 000000000..844177b1d
--- /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 000000000..03fc33b3a
--- /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 1a7a20d86..b4216d9d9 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 2060328a2..9a32d9073 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 }}
+
+ {{ i18n.ts.recommendedInstances }}
+ {{ i18n.ts.recommendedInstancesDescription }}
+
+
{{ i18n.ts.theme }}
@@ -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 390db967d..cf9c7e324 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 6ea8e1a8a..16ec3d147 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 27f6c0f5b..bb0c59e4f 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 50d295a29..6b4dd1ded 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,
};
},