From e4a3e7c07e52c33991a3853eeef9947660ecff53 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Fri, 9 Jan 2026 09:25:47 +0900 Subject: [PATCH 1/3] Add Misskey reaction picker UI --- src/App.tsx | 140 +++++++++- src/domain/types.ts | 7 + src/infra/MastodonHttpClient.ts | 8 + src/infra/MisskeyHttpClient.ts | 13 + src/infra/UnifiedApiClient.ts | 8 + src/services/MastodonApi.ts | 2 + src/ui/components/ComposeBox.tsx | 19 +- src/ui/components/ReactionPicker.tsx | 382 +++++++++++++++++++++++++++ src/ui/components/StatusModal.tsx | 14 +- src/ui/components/TimelineItem.tsx | 53 +++- src/ui/styles/components.css | 35 +++ src/ui/utils/emojiCache.ts | 10 + 12 files changed, 682 insertions(+), 9 deletions(-) create mode 100644 src/ui/components/ReactionPicker.tsx create mode 100644 src/ui/utils/emojiCache.ts diff --git a/src/App.tsx b/src/App.tsx index 5355e2a..fd1a32f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Account, Status, TimelineType } from "./domain/types"; +import type { Account, Reaction, ReactionInput, Status, TimelineType } from "./domain/types"; import { AccountAdd } from "./ui/components/AccountAdd"; import { AccountSelector } from "./ui/components/AccountSelector"; import { ComposeBox } from "./ui/components/ComposeBox"; @@ -44,6 +44,85 @@ const PageHeader = ({ title }: { title: string }) => ( ); +const sortReactions = (reactions: Reaction[]) => + [...reactions].sort((a, b) => { + if (a.count === b.count) { + return a.name.localeCompare(b.name); + } + return b.count - a.count; + }); + +const buildReactionSignature = (reactions: Reaction[]) => + sortReactions(reactions).map((reaction) => + [reaction.name, reaction.count, reaction.url ?? "", reaction.isCustom ? "1" : "0", reaction.host ?? ""].join("|") + ); + +const hasSameReactions = (left: Status, right: Status) => { + if (left.myReaction !== right.myReaction) { + return false; + } + const leftSig = buildReactionSignature(left.reactions); + const rightSig = buildReactionSignature(right.reactions); + if (leftSig.length !== rightSig.length) { + return false; + } + return leftSig.every((value, index) => value === rightSig[index]); +}; + +const adjustReactionCount = ( + reactions: Reaction[], + name: string, + delta: number, + fallback?: ReactionInput +) => { + let updated = false; + const next = reactions + .map((reaction) => { + if (reaction.name !== name) { + return reaction; + } + updated = true; + const count = reaction.count + delta; + if (count <= 0) { + return null; + } + return { ...reaction, count }; + }) + .filter((reaction): reaction is Reaction => reaction !== null); + + if (!updated && delta > 0 && fallback) { + next.push({ ...fallback, count: delta }); + } + + return next; +}; + +const buildOptimisticReactionStatus = ( + status: Status, + reaction: ReactionInput, + remove: boolean +): Status => { + let nextReactions = status.reactions; + if (remove) { + nextReactions = adjustReactionCount(nextReactions, reaction.name, -1); + } else { + if (status.myReaction && status.myReaction !== reaction.name) { + nextReactions = adjustReactionCount(nextReactions, status.myReaction, -1); + } + nextReactions = adjustReactionCount(nextReactions, reaction.name, 1, reaction); + } + const sorted = sortReactions(nextReactions); + const favouritesCount = sorted.reduce((sum, item) => sum + item.count, 0); + const myReaction = remove ? null : reaction.name; + return { + ...status, + reactions: sorted, + myReaction, + favouritesCount, + favourited: Boolean(myReaction) + }; +}; + const TimelineIcon = ({ timeline }: { timeline: TimelineType }) => { switch (timeline) { case "home": @@ -151,6 +230,7 @@ const TimelineSection = ({ onReply, onStatusClick, onCloseStatusModal, + onReact, onError, onMoveSection, canMoveLeft, @@ -174,6 +254,7 @@ const TimelineSection = ({ onRemoveSection: (sectionId: string) => void; onReply: (status: Status, account: Account | null) => void; onStatusClick: (status: Status, columnAccount: Account | null) => void; + onReact: (account: Account | null, status: Status, reaction: ReactionInput) => void; onError: (message: string | null) => void; columnAccount: Account | null; onMoveSection: (sectionId: string, direction: "left" | "right") => void; @@ -420,6 +501,13 @@ const TimelineSection = ({ } }; + const handleReact = useCallback( + (status: Status, reaction: ReactionInput) => { + onReact(account, status, reaction); + }, + [account, onReact] + ); + const handleDeleteStatus = async (status: Status) => { if (!account) { return; @@ -575,11 +663,14 @@ const TimelineSection = ({ onToggleFavourite={handleToggleFavourite} onToggleReblog={handleToggleReblog} onDelete={handleDeleteStatus} + onReact={handleReact} activeHandle={ account?.handle ? formatHandle(account.handle, account.instanceUrl) : account?.instanceUrl ?? "" } activeAccountHandle={account?.handle ?? ""} activeAccountUrl={account?.url ?? null} + account={account} + api={services.api} showProfileImage={showProfileImage} showCustomEmojis={showCustomEmojis} showReactions={showReactions} @@ -701,11 +792,14 @@ const TimelineSection = ({ onToggleFavourite={handleToggleFavourite} onToggleReblog={handleToggleReblog} onDelete={handleDeleteStatus} + onReact={handleReact} activeHandle={ account.handle ? formatHandle(account.handle, account.instanceUrl) : account.instanceUrl } activeAccountHandle={account.handle ?? ""} activeAccountUrl={account.url ?? null} + account={account} + api={services.api} showProfileImage={showProfileImage} showCustomEmojis={showCustomEmojis} showReactions={showReactions} @@ -881,6 +975,14 @@ export const App = () => { listeners.forEach((listener) => listener(status)); }, []); + const updateStatusEverywhere = useCallback( + (accountId: string, status: Status) => { + broadcastStatusUpdate(accountId, status); + setSelectedStatus((current) => (current && current.id === status.id ? status : current)); + }, + [broadcastStatusUpdate] + ); + useEffect(() => { const onHashChange = () => setRoute(parseRoute()); window.addEventListener("hashchange", onHashChange); @@ -1211,6 +1313,39 @@ export const App = () => { setSelectedStatus(null); }; + const handleReaction = useCallback( + async (account: Account | null, status: Status, reaction: ReactionInput) => { + if (!account) { + setActionError("계정을 선택해주세요."); + return; + } + if (account.platform !== "misskey") { + setActionError("리액션은 미스키 계정에서만 사용할 수 있습니다."); + return; + } + if (status.myReaction && status.myReaction !== reaction.name) { + setActionError("이미 리액션을 남겼습니다. 먼저 취소해주세요."); + return; + } + setActionError(null); + const isRemoving = status.myReaction === reaction.name; + const optimistic = buildOptimisticReactionStatus(status, reaction, isRemoving); + updateStatusEverywhere(account.id, optimistic); + try { + const updated = isRemoving + ? await services.api.deleteReaction(account, status.id) + : await services.api.createReaction(account, status.id, reaction.name); + if (!hasSameReactions(updated, optimistic)) { + updateStatusEverywhere(account.id, updated); + } + } catch (err) { + setActionError(err instanceof Error ? err.message : "리액션 처리에 실패했습니다."); + updateStatusEverywhere(account.id, status); + } + }, + [services.api, updateStatusEverywhere] + ); + const composeAccountSelector = ( addSectionNear(id, "left")} onAddSectionRight={(id) => addSectionNear(id, "right")} onRemoveSection={removeSection} - onReply={handleReply} + onReply={handleReply} onStatusClick={handleStatusClick} + onReact={handleReaction} columnAccount={sectionAccount} onCloseStatusModal={handleCloseStatusModal} onError={(message) => setActionError(message || null)} diff --git a/src/domain/types.ts b/src/domain/types.ts index 8b7529d..326d461 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -43,6 +43,13 @@ export type Reaction = { host: string | null; }; +export type ReactionInput = { + name: string; + url: string | null; + isCustom: boolean; + host: string | null; +}; + export type NotificationActor = { name: string; handle: string; diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index 1e22ced..b620753 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -254,6 +254,14 @@ export class MastodonHttpClient implements MastodonApi { return this.postAction(account, statusId, "unfavourite"); } + async createReaction(_account: Account, _statusId: string, _reaction: string): Promise { + throw new Error("리액션은 미스키 계정에서만 사용할 수 있습니다."); + } + + async deleteReaction(_account: Account, _statusId: string): Promise { + throw new Error("리액션은 미스키 계정에서만 사용할 수 있습니다."); + } + async reblog(account: Account, statusId: string): Promise { return this.postAction(account, statusId, "reblog"); } diff --git a/src/infra/MisskeyHttpClient.ts b/src/infra/MisskeyHttpClient.ts index 6316536..6a1bf34 100644 --- a/src/infra/MisskeyHttpClient.ts +++ b/src/infra/MisskeyHttpClient.ts @@ -277,6 +277,19 @@ export class MisskeyHttpClient implements MastodonApi { return this.fetchNote(account, statusId); } + async createReaction(account: Account, statusId: string, reaction: string): Promise { + await this.postSimple(account, "/api/notes/reactions/create", { + noteId: statusId, + reaction + }); + return this.fetchNote(account, statusId); + } + + async deleteReaction(account: Account, statusId: string): Promise { + await this.postSimple(account, "/api/notes/reactions/delete", { noteId: statusId }); + return this.fetchNote(account, statusId); + } + async reblog(account: Account, statusId: string): Promise { await this.postSimple(account, "/api/notes/create", { renoteId: statusId }); return this.fetchNote(account, statusId); diff --git a/src/infra/UnifiedApiClient.ts b/src/infra/UnifiedApiClient.ts index a7eb1c0..c8c68d5 100644 --- a/src/infra/UnifiedApiClient.ts +++ b/src/infra/UnifiedApiClient.ts @@ -53,6 +53,14 @@ export class UnifiedApiClient implements MastodonApi { return this.getClient(account).unfavourite(account, statusId); } + createReaction(account: Account, statusId: string, reaction: string) { + return this.getClient(account).createReaction(account, statusId, reaction); + } + + deleteReaction(account: Account, statusId: string) { + return this.getClient(account).deleteReaction(account, statusId); + } + reblog(account: Account, statusId: string) { return this.getClient(account).reblog(account, statusId); } diff --git a/src/services/MastodonApi.ts b/src/services/MastodonApi.ts index 702a00c..653f3b5 100644 --- a/src/services/MastodonApi.ts +++ b/src/services/MastodonApi.ts @@ -19,6 +19,8 @@ export interface MastodonApi { deleteStatus(account: Account, statusId: string): Promise; favourite(account: Account, statusId: string): Promise; unfavourite(account: Account, statusId: string): Promise; + createReaction(account: Account, statusId: string, reaction: string): Promise; + deleteReaction(account: Account, statusId: string): Promise; reblog(account: Account, statusId: string): Promise; unreblog(account: Account, statusId: string): Promise; fetchInstanceInfo(account: Account): Promise; diff --git a/src/ui/components/ComposeBox.tsx b/src/ui/components/ComposeBox.tsx index 5976ce5..9517723 100644 --- a/src/ui/components/ComposeBox.tsx +++ b/src/ui/components/ComposeBox.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Account, CustomEmoji, Visibility } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; +import { getCachedEmojis, setCachedEmojis } from "../utils/emojiCache"; import { calculateCharacterCount, getCharacterLimit, @@ -300,6 +301,15 @@ export const ComposeBox = ({ if (!activeInstanceUrl) { return; } + const cached = getCachedEmojis(activeInstanceUrl); + if (cached) { + setEmojiCatalogs((current) => ({ + ...current, + [activeInstanceUrl]: cached + })); + setEmojiLoadState((current) => ({ ...current, [activeInstanceUrl]: "loaded" })); + setEmojiErrors((current) => ({ ...current, [activeInstanceUrl]: null })); + } setRecentByInstance((current) => { if (current[activeInstanceUrl]) { return current; @@ -321,11 +331,19 @@ export const ComposeBox = ({ if (emojiStatus === "loaded") { return; } + const cached = getCachedEmojis(activeInstanceUrl); + if (cached) { + setEmojiCatalogs((current) => ({ ...current, [activeInstanceUrl]: cached })); + setEmojiLoadState((current) => ({ ...current, [activeInstanceUrl]: "loaded" })); + setEmojiErrors((current) => ({ ...current, [activeInstanceUrl]: null })); + return; + } setEmojiLoadState((current) => ({ ...current, [activeInstanceUrl]: "loading" })); setEmojiErrors((current) => ({ ...current, [activeInstanceUrl]: null })); const load = async () => { try { const emojis = await api.fetchCustomEmojis(account); + setCachedEmojis(activeInstanceUrl, emojis); setEmojiCatalogs((current) => ({ ...current, [activeInstanceUrl]: emojis })); setEmojiLoadState((current) => ({ ...current, [activeInstanceUrl]: "loaded" })); } catch (err) { @@ -764,4 +782,3 @@ export const ComposeBox = ({ ); }; - diff --git a/src/ui/components/ReactionPicker.tsx b/src/ui/components/ReactionPicker.tsx new file mode 100644 index 0000000..b1ecafe --- /dev/null +++ b/src/ui/components/ReactionPicker.tsx @@ -0,0 +1,382 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Account, CustomEmoji, ReactionInput } from "../../domain/types"; +import type { MastodonApi } from "../../services/MastodonApi"; +import { getCachedEmojis, setCachedEmojis } from "../utils/emojiCache"; + +const RECENT_EMOJI_KEY_PREFIX = "textodon.compose.recentEmojis."; +const RECENT_EMOJI_LIMIT = 24; + +const buildRecentEmojiKey = (instanceUrl: string) => + `${RECENT_EMOJI_KEY_PREFIX}${encodeURIComponent(instanceUrl)}`; + +const loadRecentEmojis = (instanceUrl: string): string[] => { + try { + const stored = localStorage.getItem(buildRecentEmojiKey(instanceUrl)); + if (!stored) { + return []; + } + const parsed = JSON.parse(stored) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter((item) => typeof item === "string"); + } catch { + return []; + } +}; + +const persistRecentEmojis = (instanceUrl: string, list: string[]) => { + try { + localStorage.setItem(buildRecentEmojiKey(instanceUrl), JSON.stringify(list)); + } catch { + return; + } +}; + +export const ReactionPicker = ({ + account, + api, + disabled = false, + onSelect +}: { + account: Account | null; + api: MastodonApi; + disabled?: boolean; + onSelect: (reaction: ReactionInput) => void; +}) => { + const [open, setOpen] = useState(false); + const [emojiState, setEmojiState] = useState<"idle" | "loading" | "loaded" | "error">("idle"); + const [emojis, setEmojis] = useState([]); + const [emojiError, setEmojiError] = useState(null); + const [panelStyle, setPanelStyle] = useState({}); + const [recentByInstance, setRecentByInstance] = useState>({}); + const [expandedByInstance, setExpandedByInstance] = useState>>({}); + const [recentOpen, setRecentOpen] = useState(true); + const instanceUrl = account?.instanceUrl ?? null; + const buttonRef = useRef(null); + const panelRef = useRef(null); + const emojiMap = useMemo(() => new Map(emojis.map((emoji) => [emoji.shortcode, emoji])), [emojis]); + const recentShortcodes = instanceUrl ? recentByInstance[instanceUrl] ?? [] : []; + const recentEmojis = useMemo( + () => recentShortcodes.map((shortcode) => emojiMap.get(shortcode)).filter(Boolean) as CustomEmoji[], + [emojiMap, recentShortcodes] + ); + + const categorizedEmojis = useMemo(() => { + const grouped = new Map(); + emojis.forEach((emoji) => { + const category = emoji.category?.trim() || "기타"; + const list = grouped.get(category) ?? []; + list.push(emoji); + grouped.set(category, list); + }); + return Array.from(grouped.entries()) + .sort(([a], [b]) => a.localeCompare(b, "ko-KR")) + .map(([label, list]) => ({ id: `category:${label}`, label, emojis: list })); + }, [emojis]); + + const emojiCategories = useMemo(() => { + const categories = [...categorizedEmojis]; + if (recentEmojis.length > 0) { + categories.unshift({ id: "recent", label: "최근 사용", emojis: recentEmojis }); + } + return categories; + }, [categorizedEmojis, recentEmojis]); + const expandedCategories = instanceUrl ? expandedByInstance[instanceUrl] ?? new Set() : new Set(); + + useEffect(() => { + if (!instanceUrl) { + setEmojis([]); + setEmojiState("idle"); + setEmojiError(null); + return; + } + const cached = getCachedEmojis(instanceUrl); + if (cached) { + setEmojis(cached); + setEmojiState("loaded"); + setEmojiError(null); + return; + } + setEmojis([]); + setEmojiState("idle"); + setEmojiError(null); + }, [instanceUrl]); + + useEffect(() => { + if (!instanceUrl) { + return; + } + setRecentByInstance((current) => { + if (current[instanceUrl]) { + return current; + } + return { ...current, [instanceUrl]: loadRecentEmojis(instanceUrl) }; + }); + setExpandedByInstance((current) => { + if (current[instanceUrl]) { + return current; + } + return { ...current, [instanceUrl]: new Set() }; + }); + }, [instanceUrl]); + + useEffect(() => { + if (!open || !account) { + return; + } + if (emojiState === "loaded") { + return; + } + if (instanceUrl) { + const cached = getCachedEmojis(instanceUrl); + if (cached) { + setEmojis(cached); + setEmojiState("loaded"); + setEmojiError(null); + return; + } + } + let cancelled = false; + const loadEmojis = async () => { + setEmojiState("loading"); + setEmojiError(null); + try { + const list = await api.fetchCustomEmojis(account); + if (cancelled) { + return; + } + setCachedEmojis(account.instanceUrl, list); + setEmojis(list); + setEmojiState("loaded"); + } catch (err) { + if (cancelled) { + return; + } + setEmojiState("error"); + setEmojiError(err instanceof Error ? err.message : "이모지를 불러오지 못했습니다."); + } + }; + void loadEmojis(); + return () => { + cancelled = true; + }; + }, [account, api, emojiState, open]); + + useEffect(() => { + if (!open) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + const handleClick = (event: MouseEvent) => { + if (!(event.target instanceof Node)) { + return; + } + if (buttonRef.current?.contains(event.target)) { + return; + } + if (panelRef.current?.contains(event.target)) { + return; + } + setOpen(false); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("mousedown", handleClick); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("mousedown", handleClick); + }; + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + setRecentOpen(true); + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + let frame = 0; + const updatePosition = () => { + if (!buttonRef.current || !panelRef.current) { + return; + } + const buttonRect = buttonRef.current.getBoundingClientRect(); + const panelRect = panelRef.current.getBoundingClientRect(); + const margin = 12; + let top = buttonRect.top - panelRect.height - margin; + const shouldFlip = top < margin; + if (shouldFlip) { + top = Math.min(window.innerHeight - panelRect.height - margin, buttonRect.bottom + margin); + } + let left = buttonRect.right - panelRect.width; + if (left < margin) { + left = margin; + } + if (left + panelRect.width > window.innerWidth - margin) { + left = window.innerWidth - panelRect.width - margin; + } + setPanelStyle({ top, left }); + }; + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(updatePosition); + }; + schedule(); + window.addEventListener("resize", schedule); + window.addEventListener("scroll", schedule, true); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("resize", schedule); + window.removeEventListener("scroll", schedule, true); + }; + }, [open, emojis.length]); + + const categories = useMemo(() => { + const grouped = new Map(); + emojis.forEach((emoji) => { + const category = emoji.category?.trim() || "기타"; + const list = grouped.get(category) ?? []; + list.push(emoji); + grouped.set(category, list); + }); + return Array.from(grouped.entries()) + .sort(([a], [b]) => a.localeCompare(b, "ko-KR")) + .map(([label, list]) => ({ + label, + emojis: [...list].sort((a, b) => a.shortcode.localeCompare(b.shortcode, "ko-KR")) + })); + }, [emojis]); + + const handleSelect = useCallback( + (emoji: CustomEmoji) => { + if (!instanceUrl) { + return; + } + onSelect({ + name: `:${emoji.shortcode}:`, + url: emoji.url, + isCustom: true, + host: null + }); + setRecentByInstance((current) => { + const currentList = current[instanceUrl] ?? []; + const filtered = currentList.filter((item) => item !== emoji.shortcode); + const nextList = [emoji.shortcode, ...filtered].slice(0, RECENT_EMOJI_LIMIT); + persistRecentEmojis(instanceUrl, nextList); + return { ...current, [instanceUrl]: nextList }; + }); + setOpen(false); + }, + [instanceUrl, onSelect] + ); + + const toggleCategory = (categoryId: string) => { + if (!instanceUrl) { + return; + } + if (categoryId === "recent") { + setRecentOpen((current) => !current); + return; + } + setExpandedByInstance((current) => { + const next = new Set(current[instanceUrl] ?? []); + if (next.has(categoryId)) { + next.delete(categoryId); + } else { + next.add(categoryId); + } + return { ...current, [instanceUrl]: next }; + }); + }; + + return ( +
+ + {open ? ( + <> +
setOpen(false)} aria-hidden="true" /> +
+
+ {!account ?

계정을 선택해주세요.

: null} + {account && emojiState === "loading" ? ( +

이모지를 불러오는 중...

+ ) : null} + {account && emojiState === "error" ? ( +
+

{emojiError ?? "이모지를 불러오지 못했습니다."}

+ +
+ ) : null} + {account && emojiState === "loaded" && emojiCategories.length === 0 ? ( +

사용할 수 있는 커스텀 이모지가 없습니다.

+ ) : null} + {account && emojiState === "loaded" + ? emojiCategories.map((category) => { + const categoryKey = `${instanceUrl ?? "unknown"}::${category.id}`; + const isCollapsed = + category.id === "recent" ? !recentOpen : !expandedCategories.has(category.id); + return ( +
+ + {isCollapsed ? null : ( +
+ {category.emojis.map((emoji) => ( + + ))} +
+ )} +
+ ); + }) + : null} +
+
+ + ) : null} +
+ ); +}; diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx index 8849f2a..53a719a 100644 --- a/src/ui/components/StatusModal.tsx +++ b/src/ui/components/StatusModal.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; import type { Account, Status, ThreadContext } from "../../domain/types"; +import type { MastodonApi } from "../../services/MastodonApi"; import { TimelineItem } from "./TimelineItem"; import boostIconUrl from "../assets/boost-icon.svg"; @@ -23,7 +24,7 @@ export const StatusModal = ({ status: Status; account: Account | null; threadAccount: Account | null; - api: any; // UnifiedApiClient + api: MastodonApi; onClose: () => void; onReply: (status: Status) => void; onToggleFavourite: (status: Status) => void; @@ -115,10 +116,13 @@ export const StatusModal = ({ activeHandle={activeHandle} activeAccountHandle={activeAccountHandle} activeAccountUrl={activeAccountUrl} + account={account} + api={api} showProfileImage={showProfileImage} showCustomEmojis={showCustomEmojis} showReactions={showReactions} disableActions={!account} + enableReactionActions={false} />
))} @@ -141,10 +145,13 @@ export const StatusModal = ({ activeHandle={activeHandle} activeAccountHandle={activeAccountHandle} activeAccountUrl={activeAccountUrl} + account={account} + api={api} showProfileImage={showProfileImage} showCustomEmojis={showCustomEmojis} showReactions={showReactions} disableActions={!account} + enableReactionActions={false} /> {/* 스레드 컨텍스트 - 후손 게시물들 */} @@ -166,10 +173,13 @@ export const StatusModal = ({ activeHandle={activeHandle} activeAccountHandle={activeAccountHandle} activeAccountUrl={activeAccountUrl} + account={account} + api={api} showProfileImage={showProfileImage} showCustomEmojis={showCustomEmojis} showReactions={showReactions} disableActions={!account} + enableReactionActions={false} /> ))} @@ -187,4 +197,4 @@ export const StatusModal = ({ ); -}; \ No newline at end of file +}; diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index dd9134a..5b9448e 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -1,9 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { CustomEmoji, Status } from "../../domain/types"; +import type { Account, CustomEmoji, ReactionInput, Status } from "../../domain/types"; +import type { MastodonApi } from "../../services/MastodonApi"; import { sanitizeHtml } from "../utils/htmlSanitizer"; import boostIconUrl from "../assets/boost-icon.svg"; import replyIconUrl from "../assets/reply-icon.svg"; import trashIconUrl from "../assets/trash-icon.svg"; +import { ReactionPicker } from "./ReactionPicker"; export const TimelineItem = ({ status, @@ -11,21 +13,28 @@ export const TimelineItem = ({ onToggleFavourite, onToggleReblog, onDelete, + onReact, onStatusClick, + account, + api, activeHandle, activeAccountHandle, activeAccountUrl, showProfileImage, showCustomEmojis, showReactions, - disableActions = false + disableActions = false, + enableReactionActions = true }: { status: Status; onReply: (status: Status) => void; onToggleFavourite: (status: Status) => void; onToggleReblog: (status: Status) => void; onDelete: (status: Status) => void; + onReact?: (status: Status, reaction: ReactionInput) => void; onStatusClick?: (status: Status) => void; + account: Account | null; + api: MastodonApi; activeHandle: string; activeAccountHandle: string; activeAccountUrl: string | null; @@ -33,6 +42,7 @@ export const TimelineItem = ({ showCustomEmojis: boolean; showReactions: boolean; disableActions?: boolean; + enableReactionActions?: boolean; }) => { const notification = status.notification; const displayStatus = notification?.target ?? status.reblog ?? status; @@ -525,6 +535,12 @@ export const TimelineItem = ({ !displayStatus.reblogged && (isOwnStatus || displayStatus.visibility === "private" || displayStatus.visibility === "direct"); const shouldShowReactions = showReactions && displayStatus.reactions.length > 0; const actionsEnabled = !disableActions; + const canReact = + Boolean(onReact) && + enableReactionActions && + actionsEnabled && + showReactions && + account?.platform === "misskey"; const hasAttachmentButtons = showContent && attachments.length > 0; const shouldRenderFooter = actionsEnabled || hasAttachmentButtons; @@ -591,6 +607,16 @@ export const TimelineItem = ({ }; }, [clampOffset, imageZoom, isDragging]); + const handleReactionSelect = useCallback( + (reaction: ReactionInput) => { + if (!canReact || !onReact) { + return; + } + onReact(displayStatus, reaction); + }, + [canReact, displayStatus, onReact] + ); + return (
{notificationLabel ? ( @@ -756,10 +782,21 @@ export const TimelineItem = ({ const label = formatReactionLabel(reaction); const isMine = displayStatus.myReaction === reaction.name; return ( - + handleReactionSelect({ + name: reaction.name, + url: reaction.url, + isCustom: reaction.isCustom, + host: reaction.host + }) + } + disabled={!canReact} > {reaction.url ? ( )} {reaction.count} - + ); })} @@ -805,6 +842,14 @@ export const TimelineItem = ({ {displayStatus.reblogged ? "부스트 취소" : "부스트"} {displayStatus.reblogsCount > 0 ? ` (${displayStatus.reblogsCount})` : ""} + {canReact ? ( + + ) : null} ) : null} {showContent diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 9f19f18..60a3216 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1220,6 +1220,9 @@ button.ghost { border: 1px solid var(--color-reaction-border); font-size: 13px; line-height: 1.2; + font-family: inherit; + cursor: pointer; + appearance: none; } .status-reaction.is-active { @@ -1228,6 +1231,11 @@ button.ghost { color: var(--color-reaction-active-text); } +.status-reaction:disabled { + cursor: default; + opacity: 0.7; +} + .status-reaction-emoji { font-size: 18px; line-height: 1; @@ -1361,6 +1369,33 @@ button.ghost { flex-wrap: wrap; } +.reaction-picker { + position: relative; +} + +.reaction-picker-toggle { + min-width: 72px; + border: 1px solid transparent; +} + +.reaction-picker-toggle.is-active { + background: var(--color-emoji-toggle-bg); + color: var(--color-emoji-toggle-text); + border-color: var(--color-emoji-toggle-border); +} + +.reaction-picker-panel { + position: fixed; + z-index: 20; +} + +.reaction-emoji-panel { + margin-top: 0; + width: min(320px, 85vw); + max-height: 320px; + box-shadow: var(--shadow-section-menu); +} + .delete-button { width: 32px; height: 32px; diff --git a/src/ui/utils/emojiCache.ts b/src/ui/utils/emojiCache.ts new file mode 100644 index 0000000..03974df --- /dev/null +++ b/src/ui/utils/emojiCache.ts @@ -0,0 +1,10 @@ +import type { CustomEmoji } from "../../domain/types"; + +const emojiCache = new Map(); + +export const getCachedEmojis = (instanceUrl: string): CustomEmoji[] | null => + emojiCache.get(instanceUrl) ?? null; + +export const setCachedEmojis = (instanceUrl: string, emojis: CustomEmoji[]) => { + emojiCache.set(instanceUrl, emojis); +}; From d8aa6fc3a5c9d28f8bb208f4f8661b279c9de652 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Fri, 9 Jan 2026 09:30:34 +0900 Subject: [PATCH 2/3] Simplify reaction button styling --- src/ui/components/ReactionPicker.tsx | 2 +- src/ui/styles/components.css | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/ui/components/ReactionPicker.tsx b/src/ui/components/ReactionPicker.tsx index b1ecafe..ac00391 100644 --- a/src/ui/components/ReactionPicker.tsx +++ b/src/ui/components/ReactionPicker.tsx @@ -301,7 +301,7 @@ export const ReactionPicker = ({
- + {account?.platform !== "misskey" ? ( + + ) : null}