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>