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);