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
This commit is contained in:
		
							parent
							
								
									9b092e918a
								
							
						
					
					
						commit
						0e3213ff6d
					
				
					 4 changed files with 227 additions and 94 deletions
				
			
		|  | @ -94,12 +94,7 @@ | ||||||
| 					<template v-else><i class="fas fa-reply"></i></template> | 					<template v-else><i class="fas fa-reply"></i></template> | ||||||
| 					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> | 					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> | ||||||
| 				</button> | 				</button> | ||||||
| 				<button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> | 				<XRenoteButton :note="appearNote" :count="appearNote.renoteCount" 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> |  | ||||||
| 				<button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> | 				<button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> | ||||||
| 					<i class="fas fa-plus"></i> | 					<i class="fas fa-plus"></i> | ||||||
| 				</button> | 				</button> | ||||||
|  | @ -136,16 +131,17 @@ import XReactionsViewer from './reactions-viewer.vue'; | ||||||
| import XMediaList from './media-list.vue'; | import XMediaList from './media-list.vue'; | ||||||
| import XCwButton from './cw-button.vue'; | import XCwButton from './cw-button.vue'; | ||||||
| import XPoll from './poll.vue'; | import XPoll from './poll.vue'; | ||||||
| import { pleaseLogin } from '@/scripts/please-login'; | import XRenoteButton from './renote-button.vue'; | ||||||
| import { focusPrev, focusNext } from '@/scripts/focus'; | import { pleaseLogin } from '@client/scripts/please-login'; | ||||||
| import { url } from '@/config'; | import { focusPrev, focusNext } from '@client/scripts/focus'; | ||||||
| import copyToClipboard from '@/scripts/copy-to-clipboard'; | import { url } from '@client/config'; | ||||||
| import { checkWordMute } from '@/scripts/check-word-mute'; | import copyToClipboard from '@client/scripts/copy-to-clipboard'; | ||||||
| import { userPage } from '@/filters/user'; | import { checkWordMute } from '@client/scripts/check-word-mute'; | ||||||
| import * as os from '@/os'; | import { userPage } from '@client/filters/user'; | ||||||
| import { noteActions, noteViewInterruptors } from '@/store'; | import * as os from '@client/os'; | ||||||
| import { reactionPicker } from '@/scripts/reaction-picker'; | import { noteActions, noteViewInterruptors } from '@client/store'; | ||||||
| import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; | import { reactionPicker } from '@client/scripts/reaction-picker'; | ||||||
|  | import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; | ||||||
| 
 | 
 | ||||||
| // TODO: note.vueとほぼ同じなので共通化したい | // TODO: note.vueとほぼ同じなので共通化したい | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|  | @ -157,8 +153,9 @@ export default defineComponent({ | ||||||
| 		XMediaList, | 		XMediaList, | ||||||
| 		XCwButton, | 		XCwButton, | ||||||
| 		XPoll, | 		XPoll, | ||||||
| 		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), | 		XRenoteButton, | ||||||
| 		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), | 		MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), | ||||||
|  | 		MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')), | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
| 	inject: { | 	inject: { | ||||||
|  | @ -197,7 +194,7 @@ export default defineComponent({ | ||||||
| 			return { | 			return { | ||||||
| 				'r': () => this.reply(true), | 				'r': () => this.reply(true), | ||||||
| 				'e|a|plus': () => this.react(true), | 				'e|a|plus': () => this.react(true), | ||||||
| 				'q': () => this.renote(true), | 				'q': () => this.$refs.renoteButton.renote(true), | ||||||
| 				'f|b': this.favorite, | 				'f|b': this.favorite, | ||||||
| 				'delete|ctrl+d': this.del, | 				'delete|ctrl+d': this.del, | ||||||
| 				'ctrl+q': this.renoteDirectly, | 				'ctrl+q': this.renoteDirectly, | ||||||
|  | @ -238,10 +235,6 @@ export default defineComponent({ | ||||||
| 			return this.$i && (this.$i.id === this.note.userId); | 			return this.$i && (this.$i.id === this.note.userId); | ||||||
| 		}, | 		}, | ||||||
| 
 | 
 | ||||||
| 		canRenote(): boolean { |  | ||||||
| 			return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		reactionsCount(): number { | 		reactionsCount(): number { | ||||||
| 			return this.appearNote.reactions | 			return this.appearNote.reactions | ||||||
| 				? sum(Object.values(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() { | 		renoteDirectly() { | ||||||
| 			os.apiWithDialog('notes/create', { | 			os.apiWithDialog('notes/create', { | ||||||
| 				renoteId: this.appearNote.id | 				renoteId: this.appearNote.id | ||||||
|  |  | ||||||
|  | @ -78,12 +78,7 @@ | ||||||
| 					<template v-else><i class="fas fa-reply"></i></template> | 					<template v-else><i class="fas fa-reply"></i></template> | ||||||
| 					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> | 					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> | ||||||
| 				</button> | 				</button> | ||||||
| 				<button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> | 				<XRenoteButton :note="appearNote" :count="appearNote.renoteCount" 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> |  | ||||||
| 				<button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> | 				<button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> | ||||||
| 					<i class="fas fa-plus"></i> | 					<i class="fas fa-plus"></i> | ||||||
| 				</button> | 				</button> | ||||||
|  | @ -119,16 +114,17 @@ import XReactionsViewer from './reactions-viewer.vue'; | ||||||
| import XMediaList from './media-list.vue'; | import XMediaList from './media-list.vue'; | ||||||
| import XCwButton from './cw-button.vue'; | import XCwButton from './cw-button.vue'; | ||||||
| import XPoll from './poll.vue'; | import XPoll from './poll.vue'; | ||||||
| import { pleaseLogin } from '@/scripts/please-login'; | import XRenoteButton from './renote-button.vue'; | ||||||
| import { focusPrev, focusNext } from '@/scripts/focus'; | import { pleaseLogin } from '@client/scripts/please-login'; | ||||||
| import { url } from '@/config'; | import { focusPrev, focusNext } from '@client/scripts/focus'; | ||||||
| import copyToClipboard from '@/scripts/copy-to-clipboard'; | import { url } from '@client/config'; | ||||||
| import { checkWordMute } from '@/scripts/check-word-mute'; | import copyToClipboard from '@client/scripts/copy-to-clipboard'; | ||||||
| import { userPage } from '@/filters/user'; | import { checkWordMute } from '@client/scripts/check-word-mute'; | ||||||
| import * as os from '@/os'; | import { userPage } from '@client/filters/user'; | ||||||
| import { noteActions, noteViewInterruptors } from '@/store'; | import * as os from '@client/os'; | ||||||
| import { reactionPicker } from '@/scripts/reaction-picker'; | import { noteActions, noteViewInterruptors } from '@client/store'; | ||||||
| import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; | import { reactionPicker } from '@client/scripts/reaction-picker'; | ||||||
|  | import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
| 	components: { | 	components: { | ||||||
|  | @ -139,8 +135,9 @@ export default defineComponent({ | ||||||
| 		XMediaList, | 		XMediaList, | ||||||
| 		XCwButton, | 		XCwButton, | ||||||
| 		XPoll, | 		XPoll, | ||||||
| 		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), | 		XRenoteButton, | ||||||
| 		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), | 		MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), | ||||||
|  | 		MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')), | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
| 	inject: { | 	inject: { | ||||||
|  | @ -184,7 +181,7 @@ export default defineComponent({ | ||||||
| 			return { | 			return { | ||||||
| 				'r': () => this.reply(true), | 				'r': () => this.reply(true), | ||||||
| 				'e|a|plus': () => this.react(true), | 				'e|a|plus': () => this.react(true), | ||||||
| 				'q': () => this.renote(true), | 				'q': () => this.$refs.renoteButton.renote(true), | ||||||
| 				'f|b': this.favorite, | 				'f|b': this.favorite, | ||||||
| 				'delete|ctrl+d': this.del, | 				'delete|ctrl+d': this.del, | ||||||
| 				'ctrl+q': this.renoteDirectly, | 				'ctrl+q': this.renoteDirectly, | ||||||
|  | @ -225,10 +222,6 @@ export default defineComponent({ | ||||||
| 			return this.$i && (this.$i.id === this.note.userId); | 			return this.$i && (this.$i.id === this.note.userId); | ||||||
| 		}, | 		}, | ||||||
| 
 | 
 | ||||||
| 		canRenote(): boolean { |  | ||||||
| 			return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		reactionsCount(): number { | 		reactionsCount(): number { | ||||||
| 			return this.appearNote.reactions | 			return this.appearNote.reactions | ||||||
| 				? sum(Object.values(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() { | 		renoteDirectly() { | ||||||
| 			os.apiWithDialog('notes/create', { | 			os.apiWithDialog('notes/create', { | ||||||
| 				renoteId: this.appearNote.id | 				renoteId: this.appearNote.id | ||||||
|  |  | ||||||
							
								
								
									
										149
									
								
								packages/client/src/components/renote-button.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								packages/client/src/components/renote-button.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||||
							
								
								
									
										46
									
								
								packages/client/src/components/renote.details.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								packages/client/src/components/renote.details.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue