From 4a08d5295ed34f0ca420fa97ecfc40057b8ee339 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 6 May 2020 11:41:44 +0900
Subject: [PATCH] feat(client): Make possible to customize sidebar

Resolve #6285
---
 locales/ja-JP.yml                        |   3 +
 src/client/app.ts                        | 109 +++++++++++++++++
 src/client/app.vue                       | 145 ++++++++++-------------
 src/client/pages/preferences/index.vue   |   6 +-
 src/client/pages/preferences/sidebar.vue |  86 ++++++++++++++
 src/client/store.ts                      |  19 ++-
 webpack.config.ts                        |   8 +-
 7 files changed, 283 insertions(+), 93 deletions(-)
 create mode 100644 src/client/app.ts
 create mode 100644 src/client/pages/preferences/sidebar.vue

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index f22a76917f..ce9b0dcd6b 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -498,6 +498,9 @@ removeAllFollowing: "フォローを全解除"
 removeAllFollowingDescription: "{host}からのフォローをすべて解除します。そのインスタンスがもう存在しなくなった場合などに実行してください。"
 userSuspended: "このユーザーは凍結されています。"
 userSilenced: "このユーザーはサイレンスされています。"
+sidebar: "サイドバー"
+divider: "分割線"
+addItem: "項目を追加"
 
 _theme:
   explore: "テーマを探す"
diff --git a/src/client/app.ts b/src/client/app.ts
new file mode 100644
index 0000000000..6a03526e3e
--- /dev/null
+++ b/src/client/app.ts
@@ -0,0 +1,109 @@
+import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite } from '@fortawesome/free-solid-svg-icons';
+import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
+
+export function createMenuDef(actions) {
+	return {
+		notifications: {
+			title: 'notifications',
+			icon: faBell,
+			show: store => store.getters.isSignedIn,
+			indicate: store => store.getters.isSignedIn && store.state.i.hasUnreadNotification,
+			to: '/my/notifications',
+		},
+		messaging: {
+			title: 'messaging',
+			icon: faComments,
+			show: store => store.getters.isSignedIn,
+			indicate: store => store.getters.isSignedIn && store.state.i.hasUnreadMessagingMessage,
+			to: '/my/messaging',
+		},
+		drive: {
+			title: 'drive',
+			icon: faCloud,
+			show: store => store.getters.isSignedIn,
+			to: '/my/drive',
+		},
+		followRequests: {
+			title: 'followRequests',
+			icon: faUserClock,
+			show: store => store.getters.isSignedIn && store.state.i.isLocked,
+			indicate: store => store.getters.isSignedIn && store.state.i.hasPendingReceivedFollowRequest,
+			to: '/my/follow-requests',
+		},
+		featured: {
+			title: 'featured',
+			icon: faFireAlt,
+			to: '/featured',
+		},
+		explore: {
+			title: 'explore',
+			icon: faHashtag,
+			to: '/explore',
+		},
+		announcements: {
+			title: 'announcements',
+			icon: faBroadcastTower,
+			indicate: store => store.getters.isSignedIn && store.state.i.hasUnreadAnnouncement,
+			to: '/announcements',
+		},
+		search: {
+			title: 'search',
+			icon: faSearch,
+			action: () => actions.search(),
+		},
+		lists: {
+			title: 'lists',
+			icon: faListUl,
+			show: store => store.getters.isSignedIn,
+			to: '/my/lists',
+		},
+		groups: {
+			title: 'groups',
+			icon: faUsers,
+			show: store => store.getters.isSignedIn,
+			to: '/my/groups',
+		},
+		antennas: {
+			title: 'antennas',
+			icon: faSatellite,
+			show: store => store.getters.isSignedIn,
+			to: '/my/antennas',
+		},
+		mentions: {
+			title: 'mentions',
+			icon: faAt,
+			show: store => store.getters.isSignedIn,
+			indicate: store => store.getters.isSignedIn && store.state.i.hasUnreadMentions,
+			to: '/my/mentions',
+		},
+		messages: {
+			title: 'directNotes',
+			icon: faEnvelope,
+			show: store => store.getters.isSignedIn,
+			indicate: store => store.getters.isSignedIn && store.state.i.hasUnreadSpecifiedNotes,
+			to: '/my/messages',
+		},
+		favorites: {
+			title: 'favorites',
+			icon: faStar,
+			show: store => store.getters.isSignedIn,
+			to: '/my/favorites',
+		},
+		pages: {
+			title: 'pages',
+			icon: faFileAlt,
+			show: store => store.getters.isSignedIn,
+			to: '/my/pages',
+		},
+		games: {
+			title: 'games',
+			icon: faGamepad,
+			to: '/games',
+		},
+		scratchpad: {
+			title: 'scratchpad',
+			icon: faTerminal,
+			to: '/scratchpad',
+		},
+	};
+}
diff --git a/src/client/app.vue b/src/client/app.vue
index 4bc5710212..96f1d3ad26 100644
--- a/src/client/app.vue
+++ b/src/client/app.vue
@@ -49,44 +49,20 @@
 				<router-link class="item index" active-class="active" to="/" exact v-else>
 					<fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
 				</router-link>
-				<template v-if="$store.getters.isSignedIn">
-					<router-link class="item notifications" active-class="active" to="/my/notifications" ref="notificationButton">
-						<fa :icon="faBell" fixed-width/><span class="text">{{ $t('notifications') }}</span>
-						<i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i>
-					</router-link>
-					<router-link class="item" active-class="active" to="/my/messaging">
-						<fa :icon="faComments" fixed-width/><span class="text">{{ $t('messaging') }}</span>
-						<i v-if="$store.state.i.hasUnreadMessagingMessage"><fa :icon="faCircle"/></i>
-					</router-link>
-					<router-link class="item" active-class="active" to="/my/drive">
-						<fa :icon="faCloud" fixed-width/><span class="text">{{ $t('drive') }}</span>
-					</router-link>
-					<router-link class="item" active-class="active" to="/my/follow-requests" v-if="$store.state.i.isLocked">
-						<fa :icon="faUserClock" fixed-width/><span class="text">{{ $t('followRequests') }}</span>
-						<i v-if="$store.state.i.hasPendingReceivedFollowRequest"><fa :icon="faCircle"/></i>
-					</router-link>
+				<template v-for="item in menu">
+					<div v-if="item === '-'" class="divider"></div>
+					<component v-else-if="menuDef[item].display !== false" :is="menuDef[item].to ? 'router-link' : 'button'" class="item _button" :class="item" active-class="active" @click="() => { if (menuDef[item].action) menuDef[item].action() }" :to="menuDef[item].to">
+						<fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $t(menuDef[item].title) }}</span>
+						<i v-if="menuDef[item].indicated"><fa :icon="faCircle"/></i>
+					</component>
 				</template>
 				<div class="divider"></div>
-				<router-link class="item" active-class="active" to="/featured">
-					<fa :icon="faFireAlt" fixed-width/><span class="text">{{ $t('featured') }}</span>
-				</router-link>
-				<router-link class="item" active-class="active" to="/explore">
-					<fa :icon="faHashtag" fixed-width/><span class="text">{{ $t('explore') }}</span>
-				</router-link>
-				<router-link class="item" active-class="active" to="/announcements">
-					<fa :icon="faBroadcastTower" fixed-width/><span class="text">{{ $t('announcements') }}</span>
-					<i v-if="$store.getters.isSignedIn && $store.state.i.hasUnreadAnnouncement"><fa :icon="faCircle"/></i>
-				</router-link>
-				<button class="item _button" @click="search()">
-					<fa :icon="faSearch" fixed-width/><span class="text">{{ $t('search') }}</span>
-				</button>
-				<div class="divider"></div>
 				<button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)" @click="oepnInstanceMenu">
 					<fa :icon="faServer" fixed-width/><span class="text">{{ $t('instance') }}</span>
 				</button>
 				<button class="item _button" @click="more">
 					<fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span>
-					<i v-if="$store.getters.isSignedIn && ($store.state.i.hasUnreadMentions || $store.state.i.hasUnreadSpecifiedNotes)"><fa :icon="faCircle"/></i>
+					<i v-if="otherNavItemIndicated"><fa :icon="faCircle"/></i>
 				</button>
 				<router-link class="item" active-class="active" to="/preferences">
 					<fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span>
@@ -141,10 +117,10 @@
 	</div>
 
 	<div class="buttons">
-		<button class="button nav _button" @click="showNav = true" ref="navButton"><fa :icon="faBars"/><i v-if="$store.getters.isSignedIn && ($store.state.i.hasUnreadSpecifiedNotes || $store.state.i.hasPendingReceivedFollowRequest || $store.state.i.hasUnreadMessagingMessage || $store.state.i.hasUnreadAnnouncement)"><fa :icon="faCircle"/></i></button>
+		<button class="button nav _button" @click="showNav = true" ref="navButton"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button>
 		<button v-if="$route.name === 'index'" class="button home _button" @click="top()"><fa :icon="faHome"/></button>
 		<button v-else class="button home _button" @click="$router.push('/')"><fa :icon="faHome"/></button>
-		<button v-if="$store.getters.isSignedIn" class="button notifications _button" @click="$router.push('/my/notifications')" ref="notificationButton2"><fa :icon="faBell"/><i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i></button>
+		<button v-if="$store.getters.isSignedIn" class="button notifications _button" @click="$router.push('/my/notifications')"><fa :icon="faBell"/><i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i></button>
 		<button v-if="$store.getters.isSignedIn" class="button post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button>
 	</div>
 
@@ -156,13 +132,14 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faTerminal, faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faGamepad, faServer, faFileAlt, faSatellite, faInfoCircle, faQuestionCircle } from '@fortawesome/free-solid-svg-icons';
+import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle } from '@fortawesome/free-solid-svg-icons';
 import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons';
 import { ResizeObserver } from '@juggle/resize-observer';
 import { v4 as uuid } from 'uuid';
 import i18n from './i18n';
 import { host, instanceName } from './config';
 import { search } from './scripts/search';
+import { createMenuDef } from './app';
 
 const DESKTOP_THRESHOLD = 1100;
 
@@ -187,6 +164,9 @@ export default Vue.extend({
 			searchQuery: '',
 			searchWait: false,
 			widgetsEditMode: false,
+			menuDef: createMenuDef({
+				search: this.search
+			}),
 			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
 			canBack: false,
 			wallpaper: localStorage.getItem('wallpaper') != null,
@@ -206,6 +186,29 @@ export default Vue.extend({
 
 		widgets(): any[] {
 			return this.$store.state.deviceUser.widgets;
+		},
+
+		menu(): string[] {
+			return this.$store.state.deviceUser.menu;
+		},
+
+		otherNavItemIndicated(): boolean {
+			if (!this.$store.getters.isSignedIn) return false;
+			for (const def in this.menuDef) {
+				if (this.menu.includes(def)) continue;
+				if (this.menuDef[def].indicated) return true;
+			}
+			return false;
+		},
+
+		navIndicated(): boolean {
+			if (!this.$store.getters.isSignedIn) return false;
+			for (const def in this.menuDef) {
+				if (def === 'timeline') continue;
+				if (def === 'notifications') continue;
+				if (this.menuDef[def].indicated) return true;
+			}
+			return false;
 		}
 	},
 
@@ -238,6 +241,23 @@ export default Vue.extend({
 					id: 'c', data: {}
 				}]);
 			}
+
+			this.$store.watch(state => state.i, i => {
+				for (const def in this.menuDef) {
+					if (this.menuDef[def].indicate) {
+						Vue.set(this.menuDef[def], 'indicated', this.menuDef[def].indicate(this.$store));
+					}
+					if (this.menuDef[def].show) {
+						Vue.set(this.menuDef[def], 'display', this.menuDef[def].show(this.$store));
+					}
+				}
+			}, { immediate: true, deep: true });
+		} else {
+			for (const def in this.menuDef) {
+				if (this.menuDef[def].show) {
+					Vue.set(this.menuDef[def], 'display', this.menuDef[def].show(this.$store));
+				}
+			}
 		}
 	},
 
@@ -425,55 +445,16 @@ export default Vue.extend({
 		},
 
 		more(ev) {
+			const items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show ? def.show(this.$store) : true).map(def => ({
+				type: def.to ? 'link' : 'button',
+				text: this.$t(def.title),
+				icon: def.icon,
+				to: def.to,
+				action: def.action,
+				indicate: def.indicate ? def.indicate(this.$store) : false,
+			}));
 			this.$root.menu({
-				items: [...(this.$store.getters.isSignedIn ? [{
-					type: 'link',
-					text: this.$t('lists'),
-					to: '/my/lists',
-					icon: faListUl,
-				}, {
-					type: 'link',
-					text: this.$t('groups'),
-					to: '/my/groups',
-					icon: faUsers,
-				}, {
-					type: 'link',
-					text: this.$t('antennas'),
-					to: '/my/antennas',
-					icon: faSatellite,
-				}, {
-					type: 'link',
-					text: this.$t('mentions'),
-					to: '/my/mentions',
-					icon: faAt,
-					indicate: this.$store.state.i.hasUnreadMentions
-				}, {
-					type: 'link',
-					text: this.$t('directNotes'),
-					to: '/my/messages',
-					icon: faEnvelope,
-					indicate: this.$store.state.i.hasUnreadSpecifiedNotes
-				}, {
-					type: 'link',
-					text: this.$t('favorites'),
-					to: '/my/favorites',
-					icon: faStar,
-				}, {
-					type: 'link',
-					text: this.$t('pages'),
-					to: '/my/pages',
-					icon: faFileAlt,
-				}, {
-					type: 'link',
-					text: this.$t('games'),
-					to: '/games',
-					icon: faGamepad,
-				}, null] : []), {
-					type: 'link',
-					text: this.$t('scratchpad'),
-					to: '/scratchpad',
-					icon: faTerminal,
-				}, null, {
+				items: [...items, null, {
 					type: 'link',
 					text: this.$t('help'),
 					to: '/docs',
diff --git a/src/client/pages/preferences/index.vue b/src/client/pages/preferences/index.vue
index e6d0fd0feb..9f4bb67956 100644
--- a/src/client/pages/preferences/index.vue
+++ b/src/client/pages/preferences/index.vue
@@ -7,6 +7,8 @@
 
 	<x-theme/>
 
+	<x-sidebar/>
+
 	<section class="_card">
 		<div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div>
 		<div class="_content">
@@ -90,13 +92,13 @@
 <script lang="ts">
 import Vue from 'vue';
 import { faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons';
-import MkInput from '../../components/ui/input.vue';
 import MkButton from '../../components/ui/button.vue';
 import MkSwitch from '../../components/ui/switch.vue';
 import MkSelect from '../../components/ui/select.vue';
 import MkRadio from '../../components/ui/radio.vue';
 import MkRange from '../../components/ui/range.vue';
 import XTheme from './theme.vue';
+import XSidebar from './sidebar.vue';
 import i18n from '../../i18n';
 import { langs } from '../../config';
 
@@ -128,7 +130,7 @@ export default Vue.extend({
 
 	components: {
 		XTheme,
-		MkInput,
+		XSidebar,
 		MkButton,
 		MkSwitch,
 		MkSelect,
diff --git a/src/client/pages/preferences/sidebar.vue b/src/client/pages/preferences/sidebar.vue
new file mode 100644
index 0000000000..41fcdbc3c7
--- /dev/null
+++ b/src/client/pages/preferences/sidebar.vue
@@ -0,0 +1,86 @@
+<template>
+<section class="_card">
+	<div class="_title"><fa :icon="faListUl"/> {{ $t('sidebar') }}</div>
+	<div class="_content">
+		<mk-textarea v-model="items" tall>
+			<span>{{ $t('sidebar') }}</span>
+			<template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template>
+		</mk-textarea>
+	</div>
+	<div class="_footer">
+		<mk-button inline @click="save()" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+		<mk-button inline @click="reset()"><fa :icon="faRedo"/> {{ $t('default') }}</mk-button>
+	</div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faListUl, faSave, faRedo } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+import i18n from '../../i18n';
+import { defaultDeviceUserSettings } from '../../store';
+import { createMenuDef } from '../../app';
+
+export default Vue.extend({
+	i18n,
+
+	components: {
+		MkButton,
+		MkTextarea,
+	},
+	
+	data() {
+		return {
+			menuDef: createMenuDef({}),
+			items: '',
+			faListUl, faSave, faRedo
+		}
+	},
+
+	computed: {
+		splited(): string[] {
+			return this.items.trim().split('\n').filter(x => x.trim() !== '');
+		}
+	},
+
+	created() {
+		this.items = this.$store.state.deviceUser.menu.join('\n');
+	},
+
+	methods: {
+		async addItem() {
+			const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.deviceUser.menu.includes(k));
+			const { canceled, result: item } = await this.$root.dialog({
+				type: null,
+				title: this.$t('addItem'),
+				select: {
+					items: [...menu.map(k => ({
+						value: k, text: this.$t(this.menuDef[k].title)
+					})), ...[{
+						value: '-', text: this.$t('divider')
+					}]]
+				},
+				showCancelButton: true
+			});
+			if (canceled) return;
+			this.items = [...this.splited, item].join('\n');
+			this.save();
+		},
+
+		save() {
+			this.$store.commit('deviceUser/setMenu', this.splited);
+		},
+
+		reset() {
+			this.$store.commit('deviceUser/setMenu', defaultDeviceUserSettings.menu);
+			this.items = this.$store.state.deviceUser.menu.join('\n');
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/src/client/store.ts b/src/client/store.ts
index 3635e21f1c..c60661d45b 100644
--- a/src/client/store.ts
+++ b/src/client/store.ts
@@ -16,16 +16,27 @@ export const defaultSettings = {
 	reactions: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
 };
 
-const defaultDeviceUserSettings = {
+export const defaultDeviceUserSettings = {
 	visibility: 'public',
 	localOnly: false,
 	widgets: [],
 	tl: {
 		src: 'home'
 	},
+	menu: [
+		'notifications',
+		'messaging',
+		'drive',
+		'-',
+		'followRequests',
+		'featured',
+		'explore',
+		'announcements',
+		'search',
+	],
 };
 
-const defaultDeviceSettings = {
+export const defaultDeviceSettings = {
 	lang: null,
 	loadRawImages: false,
 	alwaysShowNsfw: false,
@@ -237,6 +248,10 @@ export default () => new Vuex.Store({
 					};
 				},
 
+				setMenu(state, menu) {
+					state.menu = menu;
+				},
+
 				setVisibility(state, visibility) {
 					state.visibility = visibility;
 				},
diff --git a/webpack.config.ts b/webpack.config.ts
index 4d7e4a4ef0..64cf4c8581 100644
--- a/webpack.config.ts
+++ b/webpack.config.ts
@@ -146,13 +146,7 @@ module.exports = {
 	resolveLoader: {
 		modules: ['node_modules']
 	},
-	cache: {
-		type: 'filesystem',
-		
-		buildDependencies: {
-			config: [__filename]
-		}
-	},
+	cache: false,
 	devtool: false, //'source-map',
 	mode: isProduction ? 'production' : 'development'
 };