From 4b7b51d5ccdcdad5134edc0232c98e9e8ce2caf5 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Fri, 12 Nov 2021 23:53:10 +0900
Subject: [PATCH] refactor(client): use composition api for tooltip logic

---
 .../client/src/components/note-detailed.vue   |  26 ++--
 packages/client/src/components/note.vue       |  26 ++--
 .../client/src/components/notification.vue    |  38 +----
 .../components/reactions-viewer.reaction.vue  | 146 ++++++++----------
 .../client/src/components/renote-button.vue   | 126 ++++++---------
 packages/client/src/scripts/use-tooltip.ts    |  44 ++++++
 6 files changed, 187 insertions(+), 219 deletions(-)
 create mode 100644 packages/client/src/scripts/use-tooltip.ts

diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue
index 09c05d7769..3b5b12a60a 100644
--- a/packages/client/src/components/note-detailed.vue
+++ b/packages/client/src/components/note-detailed.vue
@@ -94,7 +94,7 @@
 					<template v-else><i class="fas fa-reply"></i></template>
 					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
 				</button>
-				<XRenoteButton :note="appearNote" :count="appearNote.renoteCount" ref="renoteButton"/>
+				<XRenoteButton class="button" :note="appearNote" :count="appearNote.renoteCount" ref="renoteButton"/>
 				<button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
 					<i class="fas fa-plus"></i>
 				</button>
@@ -132,16 +132,16 @@ import XMediaList from './media-list.vue';
 import XCwButton from './cw-button.vue';
 import XPoll from './poll.vue';
 import XRenoteButton from './renote-button.vue';
-import { pleaseLogin } from '@client/scripts/please-login';
-import { focusPrev, focusNext } from '@client/scripts/focus';
-import { url } from '@client/config';
-import copyToClipboard from '@client/scripts/copy-to-clipboard';
-import { checkWordMute } from '@client/scripts/check-word-mute';
-import { userPage } from '@client/filters/user';
-import * as os from '@client/os';
-import { noteActions, noteViewInterruptors } from '@client/store';
-import { reactionPicker } from '@client/scripts/reaction-picker';
-import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm';
+import { pleaseLogin } from '@/scripts/please-login';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import { url } from '@/config';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { noteActions, noteViewInterruptors } from '@/store';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
 
 // TODO: note.vueとほぼ同じなので共通化したい
 export default defineComponent({
@@ -154,8 +154,8 @@ export default defineComponent({
 		XCwButton,
 		XPoll,
 		XRenoteButton,
-		MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')),
-		MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')),
+		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
 	},
 
 	inject: {
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index 19486c4dff..2ab769db43 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -78,7 +78,7 @@
 					<template v-else><i class="fas fa-reply"></i></template>
 					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
 				</button>
-				<XRenoteButton :note="appearNote" :count="appearNote.renoteCount" ref="renoteButton"/>
+				<XRenoteButton class="button" :note="appearNote" :count="appearNote.renoteCount" ref="renoteButton"/>
 				<button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
 					<i class="fas fa-plus"></i>
 				</button>
@@ -115,16 +115,16 @@ import XMediaList from './media-list.vue';
 import XCwButton from './cw-button.vue';
 import XPoll from './poll.vue';
 import XRenoteButton from './renote-button.vue';
-import { pleaseLogin } from '@client/scripts/please-login';
-import { focusPrev, focusNext } from '@client/scripts/focus';
-import { url } from '@client/config';
-import copyToClipboard from '@client/scripts/copy-to-clipboard';
-import { checkWordMute } from '@client/scripts/check-word-mute';
-import { userPage } from '@client/filters/user';
-import * as os from '@client/os';
-import { noteActions, noteViewInterruptors } from '@client/store';
-import { reactionPicker } from '@client/scripts/reaction-picker';
-import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm';
+import { pleaseLogin } from '@/scripts/please-login';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import { url } from '@/config';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { noteActions, noteViewInterruptors } from '@/store';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
 
 export default defineComponent({
 	components: {
@@ -136,8 +136,8 @@ export default defineComponent({
 		XCwButton,
 		XPoll,
 		XRenoteButton,
-		MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')),
-		MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')),
+		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
 	},
 
 	inject: {
diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue
index 1f61bee6f8..40670daa9c 100644
--- a/packages/client/src/components/notification.vue
+++ b/packages/client/src/components/notification.vue
@@ -78,6 +78,7 @@ import notePage from '@/filters/note';
 import { userPage } from '@/filters/user';
 import { i18n } from '@/i18n';
 import * as os from '@/os';
+import { useTooltip } from '@/scripts/use-tooltip';
 
 export default defineComponent({
 	components: {
@@ -153,47 +154,14 @@ export default defineComponent({
 			os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id });
 		};
 
-		let isReactionHovering = false;
-		let reactionTooltipTimeoutId;
-
-		const onReactionMouseover = () => {
-			if (isReactionHovering) return;
-			isReactionHovering = true;
-			reactionTooltipTimeoutId = setTimeout(openReactionTooltip, 300);
-		};
-
-		const onReactionMouseleave = () => {
-			if (!isReactionHovering) return;
-			isReactionHovering = false;
-			clearTimeout(reactionTooltipTimeoutId);
-			closeReactionTooltip();
-		};
-
-		let changeReactionTooltipShowingState: (() => void) | null;
-
-		const openReactionTooltip = () => {
-			closeReactionTooltip();
-			if (!isReactionHovering) return;
-
-			const showing = ref(true);
+		const { onMouseover: onReactionMouseover, onMouseleave: onReactionMouseleave } = useTooltip((showing) => {
 			os.popup(XReactionTooltip, {
 				showing,
 				reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction,
 				emojis: props.notification.note.emojis,
 				source: reactionRef.value.$el,
 			}, {}, 'closed');
-
-			changeReactionTooltipShowingState = () => {
-				showing.value = false;
-			};
-		};
-
-		const closeReactionTooltip = () => {
-			if (changeReactionTooltipShowingState != null) {
-				changeReactionTooltipShowingState();
-				changeReactionTooltipShowingState = null;
-			}
-		};
+		});
 
 		return {
 			getNoteSummary: (note: misskey.entities.Note) => getNoteSummary(note),
diff --git a/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue
index 47a3bb9720..a7769868b9 100644
--- a/packages/client/src/components/reactions-viewer.reaction.vue
+++ b/packages/client/src/components/reactions-viewer.reaction.vue
@@ -2,13 +2,13 @@
 <button
 	class="hkzvhatu _button"
 	:class="{ reacted: note.myReaction == reaction, canToggle }"
-	@click="toggleReaction(reaction)"
+	@click="toggleReaction()"
 	v-if="count > 0"
 	@touchstart.passive="onMouseover"
 	@mouseover="onMouseover"
 	@mouseleave="onMouseleave"
 	@touchend="onMouseleave"
-	ref="reaction"
+	ref="buttonRef"
 	v-particle="canToggle"
 >
 	<XReactionIcon :reaction="reaction" :custom-emojis="note.emojis"/>
@@ -17,15 +17,18 @@
 </template>
 
 <script lang="ts">
-import { defineComponent, ref } from 'vue';
+import { computed, defineComponent, onMounted, ref, watch } from 'vue';
 import XDetails from '@/components/reactions-viewer.details.vue';
 import XReactionIcon from '@/components/reaction-icon.vue';
 import * as os from '@/os';
+import { useTooltip } from '@/scripts/use-tooltip';
+import { $i } from '@/account';
 
 export default defineComponent({
 	components: {
 		XReactionIcon
 	},
+
 	props: {
 		reaction: {
 			type: String,
@@ -44,101 +47,78 @@ export default defineComponent({
 			required: true,
 		},
 	},
-	data() {
-		return {
-			close: null,
-			detailsTimeoutId: null,
-			isHovering: false
-		};
-	},
-	computed: {
-		canToggle(): boolean {
-			return !this.reaction.match(/@\w/) && this.$i;
-		},
-	},
-	watch: {
-		count(newCount, oldCount) {
-			if (oldCount < newCount) this.anime();
-			if (this.close != null) this.openDetails();
-		},
-	},
-	mounted() {
-		if (!this.isInitial) this.anime();
-	},
-	methods: {
-		toggleReaction() {
-			if (!this.canToggle) return;
 
-			const oldReaction = this.note.myReaction;
+	setup(props) {
+		const buttonRef = ref<HTMLElement>();
+
+		const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
+
+		const toggleReaction = () => {
+			if (!canToggle.value) return;
+
+			const oldReaction = props.note.myReaction;
 			if (oldReaction) {
 				os.api('notes/reactions/delete', {
-					noteId: this.note.id
+					noteId: props.note.id
 				}).then(() => {
-					if (oldReaction !== this.reaction) {
+					if (oldReaction !== props.reaction) {
 						os.api('notes/reactions/create', {
-							noteId: this.note.id,
-							reaction: this.reaction
+							noteId: props.note.id,
+							reaction: props.reaction
 						});
 					}
 				});
 			} else {
 				os.api('notes/reactions/create', {
-					noteId: this.note.id,
-					reaction: this.reaction
+					noteId: props.note.id,
+					reaction: props.reaction
 				});
 			}
-		},
-		onMouseover() {
-			if (this.isHovering) return;
-			this.isHovering = true;
-			this.detailsTimeoutId = setTimeout(this.openDetails, 300);
-		},
-		onMouseleave() {
-			if (!this.isHovering) return;
-			this.isHovering = false;
-			clearTimeout(this.detailsTimeoutId);
-			this.closeDetails();
-		},
-		openDetails() {
-			os.api('notes/reactions', {
-				noteId: this.note.id,
-				type: this.reaction,
-				limit: 11
-			}).then((reactions: any[]) => {
-				const users = reactions
-					.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
-					.map(x => x.user);
+		};
 
-				this.closeDetails();
-				if (!this.isHovering) return;
-
-				const showing = ref(true);
-				os.popup(XDetails, {
-					showing,
-					reaction: this.reaction,
-					emojis: this.note.emojis,
-					users,
-					count: this.count,
-					source: this.$refs.reaction
-				}, {}, 'closed');
-
-				this.close = () => {
-					showing.value = false;
-				};
-			});
-		},
-		closeDetails() {
-			if (this.close != null) {
-				this.close();
-				this.close = null;
-			}
-		},
-		anime() {
+		const anime = () => {
 			if (document.hidden) return;
 
-			// TODO
-		},
-	}
+			// TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション
+		};
+
+		watch(() => props.count, (newCount, oldCount) => {
+			if (oldCount < newCount) anime();
+		});
+
+		onMounted(() => {
+			if (!props.isInitial) anime();
+		});
+
+		const { onMouseover, onMouseleave } = useTooltip(async (showing) => {
+			const reactions = await os.api('notes/reactions', {
+				noteId: props.note.id,
+				type: props.reaction,
+				limit: 11
+			});
+
+			const users = reactions
+				.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
+				.map(x => x.user);
+
+			os.popup(XDetails, {
+				showing,
+				reaction: props.reaction,
+				emojis: props.note.emojis,
+				users,
+				count: props.count,
+				source: buttonRef.value
+			}, {}, 'closed');
+		});
+
+		return {
+			buttonRef,
+			canToggle,
+			toggleReaction,
+			onMouseover,
+			onMouseleave,
+		};
+	},
 });
 </script>
 
diff --git a/packages/client/src/components/renote-button.vue b/packages/client/src/components/renote-button.vue
index 16ae2a2fa4..5ddc1602ed 100644
--- a/packages/client/src/components/renote-button.vue
+++ b/packages/client/src/components/renote-button.vue
@@ -1,13 +1,13 @@
 <template>
 <button
-	class="button _button canRenote"
+	class="eddddedb _button canRenote"
 	@click="renote()"
 	v-if="canRenote"
 	@touchstart.passive="onMouseover"
 	@mouseover="onMouseover"
 	@mouseleave="onMouseleave"
 	@touchend="onMouseleave"
-	ref="renoteButton"
+	ref="buttonRef"
 >
 	<i class="fas fa-retweet"></i>
 	<p class="count" v-if="count > 0">{{ count }}</p>
@@ -21,10 +21,13 @@
 </template>
 
 <script lang="ts">
-import { defineComponent, ref } from 'vue';
-import XDetails from '@client/components/renote.details.vue';
-import { pleaseLogin } from '@client/scripts/please-login';
-import * as os from '@client/os';
+import { computed, defineComponent, ref } from 'vue';
+import XDetails from '@/components/renote.details.vue';
+import { pleaseLogin } from '@/scripts/please-login';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { useTooltip } from '@/scripts/use-tooltip';
+import { i18n } from '@/i18n';
 
 export default defineComponent({
 	props: {
@@ -37,95 +40,68 @@ export default defineComponent({
 			required: true,
 		},
 	},
-	data() {
-		return {
-			close: null,
-			detailsTimeoutId: null,
-			isHovering: false
-		};
-	},
-	computed: {
-		canRenote(): boolean {
-			return ['public', 'home'].includes(this.note.visibility) || this.note.userId === this.$i.id;
-		},
-	},
-	watch: {
-		count(newCount, oldCount) {
-			if (oldCount < newCount) this.anime();
-			if (this.close != null) this.openDetails();
-		},
-	},
-	methods: {
-		renote(viaKeyboard = false) {
+
+	setup(props) {
+		const buttonRef = ref<HTMLElement>();
+
+		const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id);
+
+		const { onMouseover, onMouseleave } = useTooltip(async (showing) => {
+			const renotes = await os.api('notes/renotes', {
+				noteId: props.note.id,
+				limit: 11
+			});
+
+			const users = renotes
+				.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
+				.map(x => x.user);
+
+			if (users.length < 1) return;
+
+			os.popup(XDetails, {
+				showing,
+				users,
+				count: props.count,
+				source: buttonRef.value
+			}, {}, 'closed');
+		});
+
+		const renote = (viaKeyboard = false) => {
 			pleaseLogin();
 			os.popupMenu([{
-				text: this.$ts.renote,
+				text: i18n.locale.renote,
 				icon: 'fas fa-retweet',
 				action: () => {
 					os.api('notes/create', {
-						renoteId: this.note.id
+						renoteId: props.note.id
 					});
 				}
 			}, {
-				text: this.$ts.quote,
+				text: i18n.locale.quote,
 				icon: 'fas fa-quote-right',
 				action: () => {
 					os.post({
-						renote: this.note,
+						renote: props.note,
 					});
 				}
-			}], this.$refs.renoteButton, {
+			}], buttonRef.value, {
 				viaKeyboard
 			});
-		},
-		onMouseover() {
-			if (this.isHovering) return;
-			this.isHovering = true;
-			this.detailsTimeoutId = setTimeout(this.openDetails, 300);
-		},
-		onMouseleave() {
-			if (!this.isHovering) return;
-			this.isHovering = false;
-			clearTimeout(this.detailsTimeoutId);
-			this.closeDetails();
-		},
-		openDetails() {
-			os.api('notes/renotes', {
-				noteId: this.note.id,
-				limit: 11
-			}).then((renotes: any[]) => {
-				const users = renotes
-					.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
-					.map(x => x.user);
+		};
 
-				this.closeDetails();
-				if (!this.isHovering || users.length < 1) return;
-
-				const showing = ref(true);
-				os.popup(XDetails, {
-					showing,
-					users,
-					count: this.count,
-					source: this.$refs.renoteButton
-				}, {}, 'closed');
-
-				this.close = () => {
-					showing.value = false;
-				};
-			});
-		},
-		closeDetails() {
-			if (this.close != null) {
-				this.close();
-				this.close = null;
-			}
-		},
-	}
+		return {
+			buttonRef,
+			canRenote,
+			renote,
+			onMouseover,
+			onMouseleave,
+		};
+	},
 });
 </script>
 
 <style lang="scss" scoped>
-.button {
+.eddddedb {
 	display: inline-block;
 	height: 32px;
 	margin: 2px;
diff --git a/packages/client/src/scripts/use-tooltip.ts b/packages/client/src/scripts/use-tooltip.ts
new file mode 100644
index 0000000000..2c0c36400d
--- /dev/null
+++ b/packages/client/src/scripts/use-tooltip.ts
@@ -0,0 +1,44 @@
+import { Ref, ref } from 'vue';
+
+export function useTooltip(onShow: (showing: Ref<boolean>) => void) {
+	let isHovering = false;
+	let timeoutId: number;
+
+	let changeShowingState: (() => void) | null;
+
+	const open = () => {
+		close();
+		if (!isHovering) return;
+
+		const showing = ref(true);
+		onShow(showing);
+		changeShowingState = () => {
+			showing.value = false;
+		};
+	};
+
+	const close = () => {
+		if (changeShowingState != null) {
+			changeShowingState();
+			changeShowingState = null;
+		}
+	};
+
+	const onMouseover = () => {
+		if (isHovering) return;
+		isHovering = true;
+		timeoutId = window.setTimeout(open, 300);
+	};
+
+	const onMouseleave = () => {
+		if (!isHovering) return;
+		isHovering = false;
+		window.clearTimeout(timeoutId);
+		close();
+	};
+
+	return {
+		onMouseover,
+		onMouseleave,
+	};
+}