From ec18c532ca65c69332771bb40d1f88276acdad94 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Sat, 2 Mar 2024 00:19:05 +0900
Subject: [PATCH] feat: ability to publish timelines on signed out page

---
 docs/api-change.md                            |  1 +
 docs/changelog.md                             |  1 +
 locales/en-US.yml                             |  2 +
 locales/ja-JP.yml                             |  2 +
 packages/backend/src/models/entities/meta.ts  |  5 +
 .../src/server/api/endpoints/admin/meta.ts    |  1 +
 .../server/api/endpoints/admin/update-meta.ts |  5 +
 .../backend/src/server/api/endpoints/meta.ts  |  8 +-
 .../api/stream/channels/global-timeline.ts    |  2 +
 .../api/stream/channels/local-timeline.ts     |  2 +
 packages/client/src/components/MkTimeline.vue |  2 +-
 packages/client/src/pages/admin/settings.vue  | 20 +++-
 packages/client/src/pages/timeline.vue        | 93 +++++++++++--------
 packages/client/src/router.ts                 |  6 ++
 packages/client/src/store.ts                  |  8 +-
 15 files changed, 115 insertions(+), 43 deletions(-)

diff --git a/docs/api-change.md b/docs/api-change.md
index c024b00d90..1fac31a7a9 100644
--- a/docs/api-change.md
+++ b/docs/api-change.md
@@ -10,6 +10,7 @@ Breaking changes are indicated by the :warning: icon.
 	- `untilDate`
 	- `withFiles`
 	- `searchCwAndAlt`
+- Added `enableGuestTimeline` field to the response of `meta` and `admin/meta`, and the request of `admin/update-meta` (optional).
 
 ## v20240301
 
diff --git a/docs/changelog.md b/docs/changelog.md
index 865ea86050..16fe645f19 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -7,6 +7,7 @@ Critical security updates are indicated by the :warning: icon.
 - Introduce new full-text search engine and post search filters
 - Refactoring
 - Show unlisted posts from following users in antennas (similar to [Fedibird](https://github.com/fedibird/mastodon/tree/fedibird) and [kmyblue](https://github.com/kmycode/mastodon), unlisted posts from people you don't follow won't be shown)
+- Add ability to publish the Local and Global timelines on `/timeline` page
 
 ## v20240301
 
diff --git a/locales/en-US.yml b/locales/en-US.yml
index 0bca5e0c19..5d81f535b0 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1189,6 +1189,8 @@ searchRange: "Posted within (optional)"
 searchRangeDescription: "If you want to filter the time period, enter it in this format: 20220615-20231031\n\nIf you leave out the year (like 0105-0106 or 20231105-0110), it's interpreted as the current year.\n\nYou can also omit either the start or end date. For example, -0102 will filter the search results to show only posts made before 2 January this year, and 20231026- will filter the results to show only posts made after 26 October 2023."
 searchPostsWithFiles: "Only posts with files"
 searchCwAndAlt: "Include content warnings and file descriptions"
+publishTimelines: "Publish timelines for visitors"
+publishTimelinesDescription: "If enabled, the Local and Global timeline will be shown on {url} even when signed out."
 
 _emojiModPerm:
   unauthorized: "None"
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index a79cda6706..033efd642e 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1009,6 +1009,8 @@ searchRange: "投稿期間(オプション)"
 searchRangeDescription: "投稿検索で投稿期間を絞りたい場合、20220615-20231031 のような形式で投稿期間を入力してください。今年の日付を指定する場合には年の指定を省略できます(0105-0106 や 20231105-0110 のように)。\n\n開始日と終了日のどちらか一方は省略可能です。例えば -0102 とすると今年1月2日までの投稿のみを、20231026- とすると2023年10月26日以降の投稿のみを検索します。"
 searchPostsWithFiles: "添付ファイルのある投稿のみ"
 searchCwAndAlt: "閲覧注意の注釈と添付ファイルの代替テキストも検索する"
+publishTimelines: "非ログインユーザーにもタイムラインを公開する"
+publishTimelinesDescription: "有効にすると、{url} でローカルタイムラインとグローバルタイムラインが公開されます。"
 
 _sensitiveMediaDetection:
   description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。"
diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts
index a14967185f..aebe97c437 100644
--- a/packages/backend/src/models/entities/meta.ts
+++ b/packages/backend/src/models/entities/meta.ts
@@ -61,6 +61,11 @@ export class Meta {
 	})
 	public disableGlobalTimeline: boolean;
 
+	@Column("boolean", {
+		default: false,
+	})
+	public enableGuestTimeline: boolean;
+
 	@Column("varchar", {
 		length: 256,
 		default: "⭐",
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index b8b645bc32..bb86042b0c 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -479,6 +479,7 @@ export default define(meta, paramDef, async () => {
 		disableLocalTimeline: instance.disableLocalTimeline,
 		disableRecommendedTimeline: instance.disableRecommendedTimeline,
 		disableGlobalTimeline: instance.disableGlobalTimeline,
+		enableGuestTimeline: instance.enableGuestTimeline,
 		driveCapacityPerLocalUserMb: instance.localDriveCapacityMb,
 		driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb,
 		emailRequiredForSignup: instance.emailRequiredForSignup,
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 765219fcdc..955a1d95d5 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -17,6 +17,7 @@ export const paramDef = {
 		disableLocalTimeline: { type: "boolean", nullable: true },
 		disableRecommendedTimeline: { type: "boolean", nullable: true },
 		disableGlobalTimeline: { type: "boolean", nullable: true },
+		enableGuestTimeline: { type: "boolean", nullable: true },
 		defaultReaction: { type: "string", nullable: true },
 		recommendedInstances: {
 			type: "array",
@@ -206,6 +207,10 @@ export default define(meta, paramDef, async (ps, me) => {
 		set.disableGlobalTimeline = ps.disableGlobalTimeline;
 	}
 
+	if (typeof ps.enableGuestTimeline === "boolean") {
+		set.enableGuestTimeline = ps.enableGuestTimeline;
+	}
+
 	if (typeof ps.defaultReaction === "string") {
 		set.defaultReaction = ps.defaultReaction;
 	}
diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts
index 819fccc826..bdfcd30dd9 100644
--- a/packages/backend/src/server/api/endpoints/meta.ts
+++ b/packages/backend/src/server/api/endpoints/meta.ts
@@ -111,6 +111,11 @@ export const meta = {
 				optional: false,
 				nullable: false,
 			},
+			enableGuestTimeline: {
+				type: "boolean",
+				optional: false,
+				nullable: false,
+			},
 			driveCapacityPerLocalUserMb: {
 				type: "number",
 				optional: false,
@@ -432,6 +437,7 @@ export default define(meta, paramDef, async (ps, me) => {
 		disableLocalTimeline: instance.disableLocalTimeline,
 		disableRecommendedTimeline: instance.disableRecommendedTimeline,
 		disableGlobalTimeline: instance.disableGlobalTimeline,
+		enableGuestTimeline: instance.enableGuestTimeline,
 		driveCapacityPerLocalUserMb: instance.localDriveCapacityMb,
 		driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb,
 		emailRequiredForSignup: instance.emailRequiredForSignup,
@@ -506,8 +512,8 @@ export default define(meta, paramDef, async (ps, me) => {
 			localTimeLine: !instance.disableLocalTimeline,
 			recommendedTimeline: !instance.disableRecommendedTimeline,
 			globalTimeLine: !instance.disableGlobalTimeline,
+			guestTimeline: instance.enableGuestTimeline,
 			emailRequiredForSignup: instance.emailRequiredForSignup,
-			searchFilters: config.meilisearch ? true : false,
 			hcaptcha: instance.enableHcaptcha,
 			recaptcha: instance.enableRecaptcha,
 			objectStorage: instance.useObjectStorage,
diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts
index 39d0719dd3..79d2fe90ec 100644
--- a/packages/backend/src/server/api/stream/channels/global-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts
@@ -23,6 +23,8 @@ export default class extends Channel {
 				return;
 		}
 
+		if (!meta.enableGuestTimeline && this.user == null) return;
+
 		this.withReplies = params != null ? !!params.withReplies : true;
 
 		// Subscribe events
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 88eabe991e..1df87dbfc8 100644
--- a/packages/backend/src/server/api/stream/channels/local-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts
@@ -22,6 +22,8 @@ export default class extends Channel {
 				return;
 		}
 
+		if (!meta.enableGuestTimeline && this.user == null) return;
+
 		this.withReplies = params != null ? !!params.withReplies : true;
 
 		// Subscribe events
diff --git a/packages/client/src/components/MkTimeline.vue b/packages/client/src/components/MkTimeline.vue
index 0c7bb68734..ade27ced52 100644
--- a/packages/client/src/components/MkTimeline.vue
+++ b/packages/client/src/components/MkTimeline.vue
@@ -1,6 +1,6 @@
 <template>
 	<MkInfo
-		v-if="tlHint && !tlHintClosed"
+		v-if="tlHint && !tlHintClosed && isSignedIn"
 		:closeable="true"
 		class="_gap"
 		@close="closeHint"
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
index b54e5349b3..32a2dd9688 100644
--- a/packages/client/src/pages/admin/settings.vue
+++ b/packages/client/src/pages/admin/settings.vue
@@ -125,6 +125,9 @@
 						</FormSection>
 
 						<FormSection>
+							<FormInfo class="_formBlock">{{
+								i18n.ts.disablingTimelinesInfo
+							}}</FormInfo>
 							<FormSwitch
 								v-model="enableLocalTimeline"
 								class="_formBlock"
@@ -135,9 +138,19 @@
 								class="_formBlock"
 								>{{ i18n.ts.enableGlobalTimeline }}</FormSwitch
 							>
+						</FormSection>
+
+						<FormSection>
 							<FormInfo class="_formBlock">{{
-								i18n.ts.disablingTimelinesInfo
+								i18n.t("publishTimelinesDescription", {
+									url: `${instanceDomain}/timelime`,
+								})
 							}}</FormInfo>
+							<FormSwitch
+								v-model="enableGuestTimeline"
+								class="_formBlock"
+								>{{ i18n.ts.publishTimelines }}</FormSwitch
+							>
 						</FormSection>
 
 						<FormSection>
@@ -467,6 +480,7 @@ const defaultDarkTheme: any = ref(null);
 const enableLocalTimeline = ref(false);
 const enableGlobalTimeline = ref(false);
 const enableRecommendedTimeline = ref(false);
+const enableGuestTimeline = ref(false);
 const pinnedUsers = ref("");
 const customMOTD = ref("");
 const recommendedInstances = ref("");
@@ -487,6 +501,7 @@ const defaultReaction = ref("");
 const defaultReactionCustom = ref("");
 const enableServerMachineStats = ref(false);
 const enableIdenticonGeneration = ref(false);
+const instanceDomain = ref("");
 
 function isValidHttpUrl(src: string) {
 	let url: URL;
@@ -522,6 +537,7 @@ function stringifyMoreUrls(src: { name: string; url: string }[]): string {
 async function init() {
 	const meta = await os.api("admin/meta");
 	if (!meta) throw new Error("No meta");
+	instanceDomain.value = meta.uri;
 	name.value = meta.name;
 	description.value = meta.description;
 	tosUrl.value = meta.tosUrl;
@@ -539,6 +555,7 @@ async function init() {
 	enableLocalTimeline.value = !meta.disableLocalTimeline;
 	enableGlobalTimeline.value = !meta.disableGlobalTimeline;
 	enableRecommendedTimeline.value = !meta.disableRecommendedTimeline;
+	enableGuestTimeline.value = meta.enableGuestTimeline;
 	pinnedUsers.value = meta.pinnedUsers.join("\n");
 	customMOTD.value = meta.customMOTD.join("\n");
 	customSplashIcons.value = meta.customSplashIcons.join("\n");
@@ -591,6 +608,7 @@ function save() {
 		disableLocalTimeline: !enableLocalTimeline.value,
 		disableGlobalTimeline: !enableGlobalTimeline.value,
 		disableRecommendedTimeline: !enableRecommendedTimeline.value,
+		enableGuestTimeline: enableGuestTimeline.value,
 		pinnedUsers: pinnedUsers.value.split("\n"),
 		customMOTD: customMOTD.value.split("\n"),
 		customSplashIcons: customSplashIcons.value.split("\n"),
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
index aaf5142035..e2aeca8917 100644
--- a/packages/client/src/pages/timeline.vue
+++ b/packages/client/src/pages/timeline.vue
@@ -75,7 +75,7 @@ import * as os from "@/os";
 import { defaultStore } from "@/store";
 import { i18n } from "@/i18n";
 import { instance } from "@/instance";
-import { $i, isSignedIn, isModerator } from "@/reactiveAccount";
+import { isModerator, isSignedIn } from "@/reactiveAccount";
 import { definePageMetadata } from "@/scripts/page-metadata";
 import { deviceKind } from "@/scripts/device-kind";
 import icon from "@/scripts/icon";
@@ -86,6 +86,7 @@ if (isSignedIn && defaultStore.reactiveState.tutorial.value !== -1) {
 	os.popup(XTutorial, {}, {}, "closed");
 }
 
+const isHomeTimelineAvailable = isSignedIn;
 const isLocalTimelineAvailable =
 	(!instance.disableLocalTimeline &&
 		(isSignedIn || instance.enableGuestTimeline)) ||
@@ -101,17 +102,20 @@ const keymap = {
 	t: focus,
 };
 
-const timelines = ["home"];
+const timelines = [];
 
-if (isLocalTimelineAvailable) {
-	timelines.push("local");
+if (isHomeTimelineAvailable) {
+	timelines.push("home");
 }
-if (isLocalTimelineAvailable) {
+if (isSocialTimelineAvailable) {
 	timelines.push("social");
 }
 if (isRecommendedTimelineAvailable) {
 	timelines.push("recommended");
 }
+if (isLocalTimelineAvailable) {
+	timelines.push("local");
+}
 if (isGlobalTimelineAvailable) {
 	timelines.push("global");
 }
@@ -130,17 +134,21 @@ window.addEventListener("resize", () => {
 const tlComponent = ref<InstanceType<typeof XTimeline>>();
 const rootEl = ref<HTMLElement>();
 
+const timelineIndex = (timeline: string): number => {
+	const index = timelines.indexOf(timeline);
+	return index === -1 ? 0 : index;
+};
+
 const src = computed({
 	get: () => defaultStore.reactiveState.tl.value.src,
 	set: (x) => {
 		saveSrc(x);
-		syncSlide(timelines.indexOf(x));
+		syncSlide(timelineIndex(x));
 	},
 });
 
-const lists = os.api("users/lists/list");
 async function chooseList(ev: MouseEvent) {
-	await lists.then((res) => {
+	await os.api("users/lists/list").then((res) => {
 		const items = [
 			{
 				type: "link" as const,
@@ -160,9 +168,8 @@ async function chooseList(ev: MouseEvent) {
 	});
 }
 
-const antennas = os.api("antennas/list");
 async function chooseAntenna(ev: MouseEvent) {
-	await antennas.then((res) => {
+	await os.api("antennas/list").then((res) => {
 		const items = [
 			{
 				type: "link" as const,
@@ -197,46 +204,44 @@ function focus(): void {
 	tlComponent.value.focus();
 }
 
-const headerActions = computed(() => [
-	{
-		icon: `${icon("ph-list-bullets")}`,
-		title: i18n.ts.lists,
-		text: i18n.ts.lists,
-		iconOnly: true,
-		handler: chooseList,
-	},
-	{
-		icon: `${icon("ph-flying-saucer")}`,
-		title: i18n.ts.antennas,
-		text: i18n.ts.antennas,
-		iconOnly: true,
-		handler: chooseAntenna,
-	} /* **TODO: fix timetravel** {
+const headerActions = computed(() =>
+	isSignedIn
+		? [
+				{
+					icon: `${icon("ph-list-bullets")}`,
+					title: i18n.ts.lists,
+					text: i18n.ts.lists,
+					iconOnly: true,
+					handler: chooseList,
+				},
+				{
+					icon: `${icon("ph-flying-saucer")}`,
+					title: i18n.ts.antennas,
+					text: i18n.ts.antennas,
+					iconOnly: true,
+					handler: chooseAntenna,
+				} /* **TODO: fix timetravel** {
 	icon: `${icon('ph-calendar-blank')}`,
 	title: i18n.ts.jumpToSpecifiedDate,
 	iconOnly: true,
 	handler: timetravel,
 } */,
-]);
+			]
+		: [],
+);
 
 const headerTabs = computed(() => [
-	{
-		key: "home",
-		title: i18n.ts._timelines.home,
-		icon: `${icon("ph-house")}`,
-		iconOnly: true,
-	},
-	...(isLocalTimelineAvailable
+	...(isHomeTimelineAvailable
 		? [
 				{
-					key: "local",
-					title: i18n.ts._timelines.local,
-					icon: `${icon("ph-users")}`,
+					key: "home",
+					title: i18n.ts._timelines.home,
+					icon: `${icon("ph-house")}`,
 					iconOnly: true,
 				},
 			]
 		: []),
-	...(isLocalTimelineAvailable
+	...(isSocialTimelineAvailable
 		? [
 				{
 					key: "social",
@@ -256,6 +261,16 @@ const headerTabs = computed(() => [
 				},
 			]
 		: []),
+	...(isLocalTimelineAvailable
+		? [
+				{
+					key: "local",
+					title: i18n.ts._timelines.local,
+					icon: `${icon("ph-users")}`,
+					iconOnly: true,
+				},
+			]
+		: []),
 	...(isGlobalTimelineAvailable
 		? [
 				{
@@ -288,7 +303,7 @@ let swiperRef: any = null;
 
 function setSwiperRef(swiper) {
 	swiperRef = swiper;
-	syncSlide(timelines.indexOf(src.value));
+	syncSlide(timelineIndex(src.value));
 }
 
 function onSlideChange() {
@@ -300,7 +315,7 @@ function syncSlide(index) {
 }
 
 onMounted(() => {
-	syncSlide(timelines.indexOf(swiperRef.activeIndex));
+	syncSlide(timelineIndex(swiperRef.activeIndex));
 });
 </script>
 
diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts
index deca773d60..d547d7177e 100644
--- a/packages/client/src/router.ts
+++ b/packages/client/src/router.ts
@@ -638,6 +638,12 @@ export const routes = [
 		component: page(() => import("./pages/my-antennas/index.vue")),
 		loginRequired: true,
 	},
+	{
+		path: "/timeline",
+		// TODO: show not-found page if meta.enableGuestTimeline is false
+		//       (currently it shows nothing if guest timelines are unavailable)
+		component: page(() => import("./pages/timeline.vue")),
+	},
 	{
 		path: "/timeline/list/:listId",
 		component: page(() => import("./pages/user-list-timeline.vue")),
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
index 23600581ef..0adaa9401b 100644
--- a/packages/client/src/store.ts
+++ b/packages/client/src/store.ts
@@ -1,5 +1,6 @@
 import { markRaw, ref } from "vue";
 import { Storage } from "./pizzax";
+import { isSignedIn } from "./reactiveAccount";
 
 export const postFormActions = [];
 export const userActions = [];
@@ -156,7 +157,12 @@ export const defaultStore = markRaw(
 		tl: {
 			where: "deviceAccount",
 			default: {
-				src: "home" as "home" | "local" | "social" | "global" | "recommended",
+				src: (isSignedIn ? "home" : "local") as
+					| "home"
+					| "local"
+					| "social"
+					| "global"
+					| "recommended",
 				arg: null,
 			},
 		},