diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ec268b89..29c318348b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Improvements - フォロー/フォロワーを非公開にできるように - インスタンスプロフィールレンダリング ready +- 通知のリアクションアイコンをホバーで拡大できるように - メールアドレスのバリデーションを強化 ### Bugfixes diff --git a/src/client/components/notification.vue b/src/client/components/notification.vue index a2e714b4e2..ce1fa5b160 100644 --- a/src/client/components/notification.vue +++ b/src/client/components/notification.vue @@ -1,5 +1,5 @@ <template> -<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }"> +<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }" ref="elRef"> <div class="head"> <MkAvatar v-if="notification.user" class="icon" :user="notification.user"/> <img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/> @@ -14,7 +14,16 @@ <i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i> <i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i> <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> - <XReactionIcon v-else-if="notification.type === 'reaction'" :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" :custom-emojis="notification.note.emojis" :no-style="true"/> + <XReactionIcon v-else-if="notification.type === 'reaction'" + :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" + :custom-emojis="notification.note.emojis" + :no-style="true" + @touchstart.passive="onReactionMouseover" + @mouseover="onReactionMouseover" + @mouseleave="onReactionMouseleave" + @touchend="onReactionMouseleave" + ref="reactionRef" + /> </div> </div> <div class="tail"> @@ -59,10 +68,11 @@ </template> <script lang="ts"> -import { defineComponent, markRaw } from 'vue'; +import { defineComponent, ref, onMounted, onUnmounted } from 'vue'; import { getNoteSummary } from '@/misc/get-note-summary'; import XReactionIcon from './reaction-icon.vue'; import MkFollowButton from './follow-button.vue'; +import XReactionTooltip from './reaction-tooltip.vue'; import notePage from '@client/filters/note'; import { userPage } from '@client/filters/user'; import { i18n } from '@client/i18n'; @@ -72,6 +82,7 @@ export default defineComponent({ components: { XReactionIcon, MkFollowButton }, + props: { notification: { type: Object, @@ -88,60 +99,117 @@ export default defineComponent({ default: false, }, }, - data() { + + setup(props) { + const elRef = ref<HTMLElement>(null); + const reactionRef = ref(null); + + onMounted(() => { + let readObserver: IntersectionObserver = null; + let connection = null; + + if (!props.notification.isRead) { + readObserver = new IntersectionObserver((entries, observer) => { + if (!entries.some(entry => entry.isIntersecting)) return; + os.stream.send('readNotification', { + id: props.notification.id + }); + entries.map(({ target }) => observer.unobserve(target)); + }); + + readObserver.observe(elRef.value); + + connection = os.stream.useChannel('main'); + connection.on('readAllNotifications', () => readObserver.unobserve(elRef.value)); + } + + onUnmounted(() => { + if (readObserver) readObserver.unobserve(elRef.value); + if (connection) connection.dispose(); + }); + }); + + const followRequestDone = ref(false); + const groupInviteDone = ref(false); + + const acceptFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/accept', { userId: props.notification.user.id }); + }; + + const rejectFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/reject', { userId: props.notification.user.id }); + }; + + const acceptGroupInvitation = () => { + groupInviteDone.value = true; + os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); + }; + + const rejectGroupInvitation = () => { + groupInviteDone.value = true; + 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; + + const openReactionTooltip = () => { + closeReactionTooltip(); + if (!isReactionHovering) return; + + const showing = ref(true); + 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: (text: string) => getNoteSummary(text, i18n.locale), - followRequestDone: false, - groupInviteDone: false, - connection: null, - readObserver: null, + followRequestDone, + groupInviteDone, + notePage, + userPage, + acceptFollowRequest, + rejectFollowRequest, + acceptGroupInvitation, + rejectGroupInvitation, + onReactionMouseover, + onReactionMouseleave, + elRef, + reactionRef, }; }, - - mounted() { - if (!this.notification.isRead) { - this.readObserver = new IntersectionObserver((entries, observer) => { - if (!entries.some(entry => entry.isIntersecting)) return; - os.stream.send('readNotification', { - id: this.notification.id - }); - entries.map(({ target }) => observer.unobserve(target)); - }); - - this.readObserver.observe(this.$el); - - this.connection = markRaw(os.stream.useChannel('main')); - this.connection.on('readAllNotifications', () => this.readObserver.unobserve(this.$el)); - } - }, - - beforeUnmount() { - if (!this.notification.isRead) { - this.readObserver.unobserve(this.$el); - this.connection.dispose(); - } - }, - - methods: { - acceptFollowRequest() { - this.followRequestDone = true; - os.api('following/requests/accept', { userId: this.notification.user.id }); - }, - rejectFollowRequest() { - this.followRequestDone = true; - os.api('following/requests/reject', { userId: this.notification.user.id }); - }, - acceptGroupInvitation() { - this.groupInviteDone = true; - os.apiWithDialog('users/groups/invitations/accept', { invitationId: this.notification.invitation.id }); - }, - rejectGroupInvitation() { - this.groupInviteDone = true; - os.api('users/groups/invitations/reject', { invitationId: this.notification.invitation.id }); - }, - notePage, - userPage - } }); </script> diff --git a/src/client/components/reaction-tooltip.vue b/src/client/components/reaction-tooltip.vue new file mode 100644 index 0000000000..93143cbe81 --- /dev/null +++ b/src/client/components/reaction-tooltip.vue @@ -0,0 +1,51 @@ +<template> +<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340"> + <div class="beeadbfb"> + <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> + <div class="name">{{ reaction.replace('@.', '') }}</div> + </div> +</MkTooltip> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkTooltip from './ui/tooltip.vue'; +import XReactionIcon from './reaction-icon.vue'; + +export default defineComponent({ + components: { + MkTooltip, + XReactionIcon, + }, + props: { + reaction: { + type: String, + required: true, + }, + emojis: { + type: Array, + required: true, + }, + source: { + required: true, + } + }, + emits: ['closed'], +}) +</script> + +<style lang="scss" scoped> +.beeadbfb { + text-align: center; + + > .icon { + display: block; + width: 60px; + margin: 0 auto; + } + + > .name { + font-size: 0.9em; + } +} +</style>