From ba4ef23d6b426f5e54d48d9519d597ca4e8b020f Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Tue, 1 Mar 2022 23:58:01 +0900
Subject: [PATCH] feat: instance default theme

---
 CHANGELOG.md                                     |  7 ++++---
 locales/ja-JP.yml                                |  3 +++
 .../1646143552768-instance-default-theme.js      | 13 +++++++++++++
 packages/backend/src/models/entities/meta.ts     | 14 ++++++++++++++
 .../server/api/endpoints/admin/update-meta.ts    | 10 ++++++++++
 .../backend/src/server/api/endpoints/meta.ts     | 10 ++++++++++
 packages/client/src/init.ts                      | 13 ++++++++++++-
 packages/client/src/pages/admin/settings.vue     | 16 ++++++++++++++++
 packages/client/src/pages/settings/theme.vue     |  8 +++++++-
 packages/client/src/store.ts                     |  4 ++++
 10 files changed, 93 insertions(+), 5 deletions(-)
 create mode 100644 packages/backend/migration/1646143552768-instance-default-theme.js

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38dce92cb2..4571d09b6b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,11 +16,12 @@ You should also include the user name that made the change.
 このバージョンからNode v16.14.0以降が必要です
 
 ### Changes
-- ノートの最大文字数を設定できる機能が廃止され、デフォルトで一律3000文字になりました
+- ノートの最大文字数を設定できる機能が廃止され、デフォルトで一律3000文字になりました @syuilo
 
 ### Improvements
-- プロフィールの追加情報を最大16まで保存できるように
-- 連合チャートにPub&Subを追加
+- インスタンスデフォルトテーマを設定できるように @syuilo
+- プロフィールの追加情報を最大16まで保存できるように @syuilo
+- 連合チャートにPub&Subを追加 @syuilo
 
 ### Bugfixes
 - Client: リアクションピッカーの高さが低くなったまま戻らないことがあるのを修正 @syuilo
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index af88f382ef..90dc14815e 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -831,6 +831,9 @@ themeColor: "テーマカラー"
 size: "サイズ"
 numberOfColumn: "列の数"
 searchByGoogle: "ググる"
+instanceDefaultLightTheme: "インスタンスデフォルトのライトテーマ"
+instanceDefaultDarkTheme: "インスタンスデフォルトのダークテーマ"
+instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入します。"
 
 _emailUnavailable:
   used: "既に使用されています"
diff --git a/packages/backend/migration/1646143552768-instance-default-theme.js b/packages/backend/migration/1646143552768-instance-default-theme.js
new file mode 100644
index 0000000000..029354fd92
--- /dev/null
+++ b/packages/backend/migration/1646143552768-instance-default-theme.js
@@ -0,0 +1,13 @@
+export class instanceDefaultTheme1646143552768 {
+    name = 'instanceDefaultTheme1646143552768'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "meta" ADD "defaultLightTheme" character varying(8192)`);
+        await queryRunner.query(`ALTER TABLE "meta" ADD "defaultDarkTheme" character varying(8192)`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "defaultDarkTheme"`);
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "defaultLightTheme"`);
+    }
+}
diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts
index 0b1e9c85c3..4d58b5f04f 100644
--- a/packages/backend/src/models/entities/meta.ts
+++ b/packages/backend/src/models/entities/meta.ts
@@ -344,6 +344,20 @@ export class Meta {
 	})
 	public feedbackUrl: string | null;
 
+	@Column('varchar', {
+		length: 8192,
+		default: null,
+		nullable: true,
+	})
+	public defaultLightTheme: string | null;
+
+	@Column('varchar', {
+		length: 8192,
+		default: null,
+		nullable: true,
+	})
+	public defaultDarkTheme: string | null;
+
 	@Column('boolean', {
 		default: false,
 	})
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 6dc6abb00a..66b634c877 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -36,6 +36,8 @@ export const paramDef = {
 		logoImageUrl: { type: 'string', nullable: true },
 		name: { type: 'string', nullable: true },
 		description: { type: 'string', nullable: true },
+		defaultLightTheme: { type: 'string', nullable: true },
+		defaultDarkTheme: { type: 'string', nullable: true },
 		localDriveCapacityMb: { type: 'integer' },
 		remoteDriveCapacityMb: { type: 'integer' },
 		cacheRemoteFiles: { type: 'boolean' },
@@ -162,6 +164,14 @@ export default define(meta, paramDef, async (ps, me) => {
 		set.description = ps.description;
 	}
 
+	if (ps.defaultLightTheme !== undefined) {
+		set.defaultLightTheme = ps.defaultLightTheme;
+	}
+
+	if (ps.defaultDarkTheme !== undefined) {
+		set.defaultDarkTheme = ps.defaultDarkTheme;
+	}
+
 	if (ps.localDriveCapacityMb !== undefined) {
 		set.localDriveCapacityMb = ps.localDriveCapacityMb;
 	}
diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts
index 312b075794..6231c35ab9 100644
--- a/packages/backend/src/server/api/endpoints/meta.ts
+++ b/packages/backend/src/server/api/endpoints/meta.ts
@@ -69,6 +69,14 @@ export const meta = {
 				optional: false, nullable: false,
 				default: false,
 			},
+			defaultDarkTheme: {
+				type: 'string',
+				optional: false, nullable: true,
+			},
+			defaultLightTheme: {
+				type: 'string',
+				optional: false, nullable: true,
+			},
 			disableRegistration: {
 				type: 'boolean',
 				optional: false, nullable: false,
@@ -504,6 +512,8 @@ export default define(meta, paramDef, async (ps, me) => {
 		logoImageUrl: instance.logoImageUrl,
 		maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため
 		emojis: await Emojis.packMany(emojis),
+		defaultLightTheme: instance.defaultLightTheme,
+		defaultDarkTheme: instance.defaultDarkTheme,
 		ads: ads.map(ad => ({
 			id: ad.id,
 			url: ad.url,
diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts
index 113324d494..ab3299d22b 100644
--- a/packages/client/src/init.ts
+++ b/packages/client/src/init.ts
@@ -15,6 +15,7 @@ if (localStorage.getItem('accounts') != null) {
 
 import { computed, createApp, watch, markRaw, version as vueVersion } from 'vue';
 import compareVersions from 'compare-versions';
+import * as JSON5 from 'json5';
 
 import widgets from '@/widgets';
 import directives from '@/directives';
@@ -159,7 +160,9 @@ if ($i && $i.token) {
 }
 //#endregion
 
-fetchInstance().then(() => {
+const fetchInstanceMetaPromise = fetchInstance();
+
+fetchInstanceMetaPromise.then(() => {
 	localStorage.setItem('v', instance.version);
 
 	// Init service worker
@@ -267,6 +270,14 @@ window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => {
 });
 //#endregion
 
+fetchInstanceMetaPromise.then(() => {
+	if (defaultStore.state.themeInitial) {
+		if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme));
+		if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme));
+		defaultStore.set('themeInitial', false);
+	}
+});
+
 // shortcut
 document.addEventListener('keydown', makeHotkey({
 	'd': () => {
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
index 5cf4d6c882..c5d7821329 100644
--- a/packages/client/src/pages/admin/settings.vue
+++ b/packages/client/src/pages/admin/settings.vue
@@ -31,6 +31,16 @@
 				<template #caption>#RRGGBB</template>
 			</FormInput>
 
+			<FormTextarea v-model="defaultLightTheme" class="_formBlock">
+				<template #label>{{ $ts.instanceDefaultLightTheme }}</template>
+				<template #caption>{{ $ts.instanceDefaultThemeDescription }}</template>
+			</FormTextarea>
+
+			<FormTextarea v-model="defaultDarkTheme" class="_formBlock">
+				<template #label>{{ $ts.instanceDefaultDarkTheme }}</template>
+				<template #caption>{{ $ts.instanceDefaultThemeDescription }}</template>
+			</FormTextarea>
+
 			<FormInput v-model="tosUrl" class="_formBlock">
 				<template #prefix><i class="fas fa-link"></i></template>
 				<template #label>{{ $ts.tosUrl }}</template>
@@ -176,6 +186,8 @@ export default defineComponent({
 			bannerUrl: null,
 			backgroundImageUrl: null,
 			themeColor: null,
+			defaultLightTheme: null,
+			defaultDarkTheme: null,
 			enableLocalTimeline: false,
 			enableGlobalTimeline: false,
 			pinnedUsers: '',
@@ -202,6 +214,8 @@ export default defineComponent({
 			this.bannerUrl = meta.bannerUrl;
 			this.backgroundImageUrl = meta.backgroundImageUrl;
 			this.themeColor = meta.themeColor;
+			this.defaultLightTheme = meta.defaultLightTheme;
+			this.defaultDarkTheme = meta.defaultDarkTheme;
 			this.maintainerName = meta.maintainerName;
 			this.maintainerEmail = meta.maintainerEmail;
 			this.enableLocalTimeline = !meta.disableLocalTimeline;
@@ -228,6 +242,8 @@ export default defineComponent({
 				bannerUrl: this.bannerUrl,
 				backgroundImageUrl: this.backgroundImageUrl,
 				themeColor: this.themeColor === '' ? null : this.themeColor,
+				defaultLightTheme: this.defaultLightTheme === '' ? null : this.defaultLightTheme,
+				defaultDarkTheme: this.defaultDarkTheme === '' ? null : this.defaultDarkTheme,
 				maintainerName: this.maintainerName,
 				maintainerEmail: this.maintainerEmail,
 				disableLocalTimeline: !this.enableLocalTimeline,
diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue
index 72b7e69174..92a6fee7a4 100644
--- a/packages/client/src/pages/settings/theme.vue
+++ b/packages/client/src/pages/settings/theme.vue
@@ -87,6 +87,7 @@
 
 <script lang="ts">
 import { computed, defineComponent, onActivated, onMounted, ref, watch } from 'vue';
+import * as JSON5 from 'json5';
 import FormSwitch from '@/components/form/switch.vue';
 import FormSelect from '@/components/form/select.vue';
 import FormGroup from '@/components/form/group.vue';
@@ -99,6 +100,8 @@ import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
 import { ColdDeviceStorage } from '@/store';
 import { i18n } from '@/i18n';
 import { defaultStore } from '@/store';
+import { instance } from '@/instance';
+import { concat } from '@/scripts/array';
 import { fetchThemes, getThemes } from '@/theme-store';
 import * as symbols from '@/symbols';
 
@@ -122,7 +125,10 @@ export default defineComponent({
 		};
 
 		const installedThemes = ref(getThemes());
-		const themes = computed(() => builtinThemes.concat(installedThemes.value));
+		const instanceThemes = [];
+		if (instance.defaultLightTheme != null) instanceThemes.push(JSON5.parse(instance.defaultLightTheme));
+		if (instance.defaultDarkTheme != null) instanceThemes.push(JSON5.parse(instance.defaultDarkTheme));
+		const themes = computed(() => instanceThemes.concat(builtinThemes.concat(installedThemes.value)));
 		const darkThemes = computed(() => themes.value.filter(t => t.base === 'dark' || t.kind === 'dark'));
 		const lightThemes = computed(() => themes.value.filter(t => t.base === 'light' || t.kind === 'light'));
 		const darkTheme = ColdDeviceStorage.ref('darkTheme');
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
index 39f5e4a65f..e6a2f42906 100644
--- a/packages/client/src/store.ts
+++ b/packages/client/src/store.ts
@@ -230,6 +230,10 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: ''
 	},
+	themeInitial: {
+		where: 'device',
+		default: true,
+	},
 	aiChanMode: {
 		where: 'device',
 		default: false