From 0e3213ff6dea77bff64bee2f46cde49ef80dafe4 Mon Sep 17 00:00:00 2001
From: Johann150 <johann.galle@protonmail.com>
Date: Fri, 12 Nov 2021 15:15:14 +0100
Subject: [PATCH] enhance: show renoters (#7954)

* refactor: deduplicate renote button into component

For now the renoters tooltip just uses the reaction viewer component
with a fixed emoji symbol instead.

* chore: remove unnecessary CSS

* fix: forgot to rename variable

* enhance: use own tooltip instead of reaction viewer

* clean up style

* fix additional renoters number

* rename file to better represent content
---
 .../client/src/components/note-detailed.vue   |  63 ++------
 packages/client/src/components/note.vue       |  63 ++------
 .../client/src/components/renote-button.vue   | 149 ++++++++++++++++++
 .../client/src/components/renote.details.vue  |  46 ++++++
 4 files changed, 227 insertions(+), 94 deletions(-)
 create mode 100644 packages/client/src/components/renote-button.vue
 create mode 100644 packages/client/src/components/renote.details.vue

diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue
index 7550153521..09c05d7769 100644
--- a/packages/client/src/components/note-detailed.vue
+++ b/packages/client/src/components/note-detailed.vue
@@ -94,12 +94,7 @@
 					<template v-else><i class="fas fa-reply"></i></template>
 					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
 				</button>
-				<button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton">
-					<i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
-				</button>
-				<button v-else class="button _button">
-					<i class="fas fa-ban"></i>
-				</button>
+				<XRenoteButton :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>
@@ -136,16 +131,17 @@ import XReactionsViewer from './reactions-viewer.vue';
 import XMediaList from './media-list.vue';
 import XCwButton from './cw-button.vue';
 import XPoll from './poll.vue';
-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';
+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';
 
 // TODO: note.vueとほぼ同じなので共通化したい
 export default defineComponent({
@@ -157,8 +153,9 @@ export default defineComponent({
 		XMediaList,
 		XCwButton,
 		XPoll,
-		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
-		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
+		XRenoteButton,
+		MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')),
+		MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')),
 	},
 
 	inject: {
@@ -197,7 +194,7 @@ export default defineComponent({
 			return {
 				'r': () => this.reply(true),
 				'e|a|plus': () => this.react(true),
-				'q': () => this.renote(true),
+				'q': () => this.$refs.renoteButton.renote(true),
 				'f|b': this.favorite,
 				'delete|ctrl+d': this.del,
 				'ctrl+q': this.renoteDirectly,
@@ -238,10 +235,6 @@ export default defineComponent({
 			return this.$i && (this.$i.id === this.note.userId);
 		},
 
-		canRenote(): boolean {
-			return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote;
-		},
-
 		reactionsCount(): number {
 			return this.appearNote.reactions
 				? sum(Object.values(this.appearNote.reactions))
@@ -459,30 +452,6 @@ export default defineComponent({
 			});
 		},
 
-		renote(viaKeyboard = false) {
-			pleaseLogin();
-			this.blur();
-			os.popupMenu([{
-				text: this.$ts.renote,
-				icon: 'fas fa-retweet',
-				action: () => {
-					os.api('notes/create', {
-						renoteId: this.appearNote.id
-					});
-				}
-			}, {
-				text: this.$ts.quote,
-				icon: 'fas fa-quote-right',
-				action: () => {
-					os.post({
-						renote: this.appearNote,
-					});
-				}
-			}], this.$refs.renoteButton, {
-				viaKeyboard
-			});
-		},
-
 		renoteDirectly() {
 			os.apiWithDialog('notes/create', {
 				renoteId: this.appearNote.id
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index b1ec674b67..19486c4dff 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -78,12 +78,7 @@
 					<template v-else><i class="fas fa-reply"></i></template>
 					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
 				</button>
-				<button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton">
-					<i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
-				</button>
-				<button v-else class="button _button">
-					<i class="fas fa-ban"></i>
-				</button>
+				<XRenoteButton :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>
@@ -119,16 +114,17 @@ import XReactionsViewer from './reactions-viewer.vue';
 import XMediaList from './media-list.vue';
 import XCwButton from './cw-button.vue';
 import XPoll from './poll.vue';
-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';
+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';
 
 export default defineComponent({
 	components: {
@@ -139,8 +135,9 @@ export default defineComponent({
 		XMediaList,
 		XCwButton,
 		XPoll,
-		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
-		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
+		XRenoteButton,
+		MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')),
+		MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')),
 	},
 
 	inject: {
@@ -184,7 +181,7 @@ export default defineComponent({
 			return {
 				'r': () => this.reply(true),
 				'e|a|plus': () => this.react(true),
-				'q': () => this.renote(true),
+				'q': () => this.$refs.renoteButton.renote(true),
 				'f|b': this.favorite,
 				'delete|ctrl+d': this.del,
 				'ctrl+q': this.renoteDirectly,
@@ -225,10 +222,6 @@ export default defineComponent({
 			return this.$i && (this.$i.id === this.note.userId);
 		},
 
-		canRenote(): boolean {
-			return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote;
-		},
-
 		reactionsCount(): number {
 			return this.appearNote.reactions
 				? sum(Object.values(this.appearNote.reactions))
@@ -435,30 +428,6 @@ export default defineComponent({
 			});
 		},
 
-		renote(viaKeyboard = false) {
-			pleaseLogin();
-			this.blur();
-			os.popupMenu([{
-				text: this.$ts.renote,
-				icon: 'fas fa-retweet',
-				action: () => {
-					os.api('notes/create', {
-						renoteId: this.appearNote.id
-					});
-				}
-			}, {
-				text: this.$ts.quote,
-				icon: 'fas fa-quote-right',
-				action: () => {
-					os.post({
-						renote: this.appearNote,
-					});
-				}
-			}], this.$refs.renoteButton, {
-				viaKeyboard
-			});
-		},
-
 		renoteDirectly() {
 			os.apiWithDialog('notes/create', {
 				renoteId: this.appearNote.id
diff --git a/packages/client/src/components/renote-button.vue b/packages/client/src/components/renote-button.vue
new file mode 100644
index 0000000000..16ae2a2fa4
--- /dev/null
+++ b/packages/client/src/components/renote-button.vue
@@ -0,0 +1,149 @@
+<template>
+<button
+	class="button _button canRenote"
+	@click="renote()"
+	v-if="canRenote"
+	@touchstart.passive="onMouseover"
+	@mouseover="onMouseover"
+	@mouseleave="onMouseleave"
+	@touchend="onMouseleave"
+	ref="renoteButton"
+>
+	<i class="fas fa-retweet"></i>
+	<p class="count" v-if="count > 0">{{ count }}</p>
+</button>
+<button
+	v-else
+	class="button _button"
+>
+	<i class="fas fa-ban"></i>
+</button>
+</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';
+
+export default defineComponent({
+	props: {
+		count: {
+			type: Number,
+			required: true,
+		},
+		note: {
+			type: Object,
+			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) {
+			pleaseLogin();
+			os.popupMenu([{
+				text: this.$ts.renote,
+				icon: 'fas fa-retweet',
+				action: () => {
+					os.api('notes/create', {
+						renoteId: this.note.id
+					});
+				}
+			}, {
+				text: this.$ts.quote,
+				icon: 'fas fa-quote-right',
+				action: () => {
+					os.post({
+						renote: this.note,
+					});
+				}
+			}], this.$refs.renoteButton, {
+				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;
+			}
+		},
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.button {
+	display: inline-block;
+	height: 32px;
+	margin: 2px;
+	padding: 0 6px;
+	border-radius: 4px;
+
+	&:not(.canRenote) {
+		cursor: default;
+	}
+
+	&.renoted {
+		background: var(--accent);
+	}
+
+	> .count {
+		display: inline;
+		margin-left: 8px;
+		opacity: 0.7;
+	}
+}
+</style>
diff --git a/packages/client/src/components/renote.details.vue b/packages/client/src/components/renote.details.vue
new file mode 100644
index 0000000000..128d97d8de
--- /dev/null
+++ b/packages/client/src/components/renote.details.vue
@@ -0,0 +1,46 @@
+<template>
+<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340">
+	<div class="renoteTooltip">
+		<b v-for="u in users" :key="u.id">
+			<MkAvatar :user="u" style="width: 24px; height: 24px;"/><br/>
+			<MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/>
+		</b>
+		<span v-if="users.length < count" slot="omitted">+{{ count - users.length }}</span>
+	</div>
+</MkTooltip>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkTooltip from './ui/tooltip.vue';
+
+export default defineComponent({
+	components: {
+		MkTooltip,
+	},
+	props: {
+		users: {
+			type: Array,
+			required: true,
+		},
+		count: {
+			type: Number,
+			required: true,
+		},
+		source: {
+			required: true,
+		}
+	},
+	emits: ['closed'],
+})
+</script>
+
+<style lang="scss" scoped>
+.renoteTooltip {
+	display: flex;
+	flex: 1;
+	min-width: 0;
+	font-size: 0.9em;
+	gap: 12px;
+}
+</style>