From 00025b0f7685bf60219dd0faa9f8b4aedbc1eb01 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 27 Aug 2024 08:29:25 +0900 Subject: [PATCH] wip --- .../components/EmReactionsViewer.reaction.vue | 241 ++++++++++++++++++ .../src/components/EmReactionsViewer.vue | 104 ++++++++ packages/frontend-embed/src/i18n.ts | 2 +- packages/frontend-embed/src/ui.vue | 7 +- 4 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue create mode 100644 packages/frontend-embed/src/components/EmReactionsViewer.vue diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue new file mode 100644 index 0000000000..26223364ab --- /dev/null +++ b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue @@ -0,0 +1,241 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<button + ref="buttonEl" + v-ripple="canToggle" + class="_button" + :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]" + @click="toggleReaction()" + @contextmenu.prevent.stop="menu" +> + <MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> + <span :class="$style.count">{{ count }}</span> +</button> +</template> + +<script lang="ts" setup> +import { computed, inject, onMounted, shallowRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; +import XDetails from '@/components/MkReactionsViewer.details.vue'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; +import * as os from '@/os.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; +import { useTooltip } from '@/scripts/use-tooltip.js'; +import { $i } from '@/account.js'; +import MkReactionEffect from '@/components/MkReactionEffect.vue'; +import { claimAchievement } from '@/scripts/achievements.js'; +import { defaultStore } from '@/store.js'; +import { i18n } from '@/i18n.js'; +import * as sound from '@/scripts/sound.js'; +import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; +import { customEmojisMap } from '@/custom-emojis.js'; +import { getUnicodeEmoji } from '@/scripts/emojilist.js'; + +const props = defineProps<{ + reaction: string; + count: number; + isInitial: boolean; + note: Misskey.entities.Note; +}>(); + +const mock = inject<boolean>('mock', false); + +const emit = defineEmits<{ + (ev: 'reactionToggled', emoji: string, newCount: number): void; +}>(); + +const buttonEl = shallowRef<HTMLElement>(); + +const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); +const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); + +const canToggle = computed(() => { + return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); +}); +const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); + +async function toggleReaction() { + if (!canToggle.value) return; + + const oldReaction = props.note.myReaction; + if (oldReaction) { + const confirm = await os.confirm({ + type: 'warning', + text: oldReaction !== props.reaction ? i18n.ts.changeReactionConfirm : i18n.ts.cancelReactionConfirm, + }); + if (confirm.canceled) return; + + if (oldReaction !== props.reaction) { + sound.playMisskeySfx('reaction'); + } + + if (mock) { + emit('reactionToggled', props.reaction, (props.count - 1)); + return; + } + + misskeyApi('notes/reactions/delete', { + noteId: props.note.id, + }).then(() => { + if (oldReaction !== props.reaction) { + misskeyApi('notes/reactions/create', { + noteId: props.note.id, + reaction: props.reaction, + }); + } + }); + } else { + sound.playMisskeySfx('reaction'); + + if (mock) { + emit('reactionToggled', props.reaction, (props.count + 1)); + return; + } + + misskeyApi('notes/reactions/create', { + noteId: props.note.id, + reaction: props.reaction, + }); + if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + } +} + +async function menu(ev) { + if (!canGetInfo.value) return; + + os.popupMenu([{ + text: i18n.ts.info, + icon: 'ti ti-info-circle', + action: async () => { + const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { + emoji: await misskeyApiGet('emoji', { + name: props.reaction.replace(/:/g, '').replace(/@\./, ''), + }), + }, { + closed: () => dispose(), + }); + }, + }], ev.currentTarget ?? ev.target); +} + +function anime() { + if (document.hidden || !defaultStore.state.animation || buttonEl.value == null) return; + + const rect = buttonEl.value.getBoundingClientRect(); + const x = rect.left + 16; + const y = rect.top + (buttonEl.value.offsetHeight / 2); + const { dispose } = os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, { + end: () => dispose(), + }); +} + +watch(() => props.count, (newCount, oldCount) => { + if (oldCount < newCount) anime(); +}); + +onMounted(() => { + if (!props.isInitial) anime(); +}); + +if (!mock) { + useTooltip(buttonEl, async (showing) => { + const reactions = await misskeyApiGet('notes/reactions', { + noteId: props.note.id, + type: props.reaction, + limit: 10, + _cacheKey_: props.count, + }); + + const users = reactions.map(x => x.user); + + const { dispose } = os.popup(XDetails, { + showing, + reaction: props.reaction, + users, + count: props.count, + targetElement: buttonEl.value, + }, { + closed: () => dispose(), + }); + }, 100); +} +</script> + +<style lang="scss" module> +.root { + display: inline-flex; + height: 42px; + margin: 2px; + padding: 0 6px; + font-size: 1.5em; + border-radius: 6px; + align-items: center; + justify-content: center; + + &.canToggle { + background: var(--buttonBg); + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + } + + &:not(.canToggle) { + cursor: default; + } + + &.small { + height: 32px; + font-size: 1em; + border-radius: 4px; + + > .count { + font-size: 0.9em; + line-height: 32px; + } + } + + &.large { + height: 52px; + font-size: 2em; + border-radius: 8px; + + > .count { + font-size: 0.6em; + line-height: 52px; + } + } + + &.reacted, &.reacted:hover { + background: var(--accentedBg); + color: var(--accent); + box-shadow: 0 0 0 1px var(--accent) inset; + + > .count { + color: var(--accent); + } + + > .icon { + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5)); + } + } +} + +.limitWidth { + max-width: 70px; + object-fit: contain; +} + +.count { + font-size: 0.7em; + line-height: 42px; + margin: 0 0 0 4px; +} +</style> diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.vue b/packages/frontend-embed/src/components/EmReactionsViewer.vue new file mode 100644 index 0000000000..49d0c6c127 --- /dev/null +++ b/packages/frontend-embed/src/components/EmReactionsViewer.vue @@ -0,0 +1,104 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> + <slot v-if="hasMoreReactions" name="more"/> +</div> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; +import { inject, watch, ref } from 'vue'; +import XReaction from '@/components/EmReactionsViewer.reaction.vue'; + +const props = withDefaults(defineProps<{ + note: Misskey.entities.Note; + maxNumber?: number; +}>(), { + maxNumber: Infinity, +}); + +const mock = inject<boolean>('mock', false); + +const emit = defineEmits<{ + (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void; +}>(); + +const initialReactions = new Set(Object.keys(props.note.reactions)); + +const reactions = ref<[string, number][]>([]); +const hasMoreReactions = ref(false); + +if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) { + reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction]; +} + +function onMockToggleReaction(emoji: string, count: number) { + if (!mock) return; + + const i = reactions.value.findIndex((item) => item[0] === emoji); + if (i < 0) return; + + emit('mockUpdateMyReaction', emoji, (count - reactions.value[i][1])); +} + +watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { + let newReactions: [string, number][] = []; + hasMoreReactions.value = Object.keys(newSource).length > maxNumber; + + for (let i = 0; i < reactions.value.length; i++) { + const reaction = reactions.value[i][0]; + if (reaction in newSource && newSource[reaction] !== 0) { + reactions.value[i][1] = newSource[reaction]; + newReactions.push(reactions.value[i]); + } + } + + const newReactionsNames = newReactions.map(([x]) => x); + newReactions = [ + ...newReactions, + ...Object.entries(newSource) + .sort(([, a], [, b]) => b - a) + .filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)), + ]; + + newReactions = newReactions.slice(0, props.maxNumber); + + if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) { + newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]); + } + + reactions.value = newReactions; +}, { immediate: true, deep: true }); +</script> + +<style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; +} +.transition_x_enterFrom, +.transition_x_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_x_leaveActive { + position: absolute; +} + +.root { + display: flex; + flex-wrap: wrap; + align-items: center; + margin: 4px -2px 0 -2px; + + &:empty { + display: none; + } +} +</style> diff --git a/packages/frontend-embed/src/i18n.ts b/packages/frontend-embed/src/i18n.ts index 10d6adbcd0..2624993560 100644 --- a/packages/frontend-embed/src/i18n.ts +++ b/packages/frontend-embed/src/i18n.ts @@ -6,7 +6,7 @@ import { markRaw } from 'vue'; import type { Locale } from '../../../locales/index.js'; import { locale } from '@/config.js'; -import { I18n } from '@/scripts/i18n.js'; +import { I18n } from '@/to-be-shared/i18n.js'; export const i18n = markRaw(new I18n<Locale>(locale)); diff --git a/packages/frontend-embed/src/ui.vue b/packages/frontend-embed/src/ui.vue index ec159e5a3b..a9c285973d 100644 --- a/packages/frontend-embed/src/ui.vue +++ b/packages/frontend-embed/src/ui.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.routerViewContainer" > - <!-- TODO --> + <EmNotePage v-if="page === 'notes'" :noteId="contentId"/> </div> </div> </template> @@ -28,9 +28,10 @@ import { ref, shallowRef, onMounted, onUnmounted, inject } from 'vue'; import type { ParsedEmbedParams } from '@/embed-page.js'; import { postMessageToParentWindow } from '@/post-message.js'; import { defaultEmbedParams } from '@/embed-page.js'; +import EmNotePage from '@/pages/note.vue'; -const page = location.href.split('/')[1]; -console.log(page); +const page = location.pathname.split('/')[2]; +const contentId = location.pathname.split('/')[3]; const embedParams = inject<ParsedEmbedParams>('embedParams', defaultEmbedParams);