From 3736ba14eacfa0ba4169898bae493a0575371724 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Sun, 11 Jan 2026 14:22:04 +0900 Subject: [PATCH 01/28] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=9D=B4=EB=AA=A8=EC=A7=80/?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domain/types.ts | 1 + src/infra/mastodonMapper.ts | 3 ++- src/infra/misskeyMapper.ts | 3 ++- src/ui/components/ProfileModal.tsx | 13 +++++++-- src/ui/utils/htmlSanitizer.ts | 42 ++++++++++++++++-------------- src/ui/utils/linkify.ts | 28 +++++++++++++------- 6 files changed, 57 insertions(+), 33 deletions(-) diff --git a/src/domain/types.ts b/src/domain/types.ts index 885b0da..61cbeec 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -119,6 +119,7 @@ export type UserProfile = { locked: boolean; bio: string; fields: ProfileField[]; + emojis?: CustomEmoji[]; }; export type AccountRelationship = { diff --git a/src/infra/mastodonMapper.ts b/src/infra/mastodonMapper.ts index 007461e..5164f58 100644 --- a/src/infra/mastodonMapper.ts +++ b/src/infra/mastodonMapper.ts @@ -128,7 +128,8 @@ export const mapAccountProfile = (raw: unknown): UserProfile => { headerUrl, locked, bio, - fields: mapProfileFields(value.fields) + fields: mapProfileFields(value.fields), + emojis: mapCustomEmojis(value.emojis) }; }; diff --git a/src/infra/misskeyMapper.ts b/src/infra/misskeyMapper.ts index 59fb7a9..217bd25 100644 --- a/src/infra/misskeyMapper.ts +++ b/src/infra/misskeyMapper.ts @@ -348,7 +348,8 @@ export const mapMisskeyUserProfile = ( headerUrl, locked, bio, - fields: mapProfileFields(value.fields) + fields: mapProfileFields(value.fields), + emojis: mapCustomEmojis(value.emojis) }; }; diff --git a/src/ui/components/ProfileModal.tsx b/src/ui/components/ProfileModal.tsx index 393ef59..112b61b 100644 --- a/src/ui/components/ProfileModal.tsx +++ b/src/ui/components/ProfileModal.tsx @@ -117,9 +117,18 @@ export const ProfileModal = ({ const [hasMore, setHasMore] = useState(true); const scrollRef = useRef(null); const targetAccountId = status.accountId; + const profileEmojis = useMemo(() => { + if (!showCustomEmojis) { + return []; + } + if (profile?.emojis && profile.emojis.length > 0) { + return profile.emojis; + } + return status.accountEmojis; + }, [profile?.emojis, showCustomEmojis, status.accountEmojis]); const emojiMap = useMemo( - () => (showCustomEmojis ? buildEmojiMap(status.accountEmojis) : new Map()), - [showCustomEmojis, status.accountEmojis] + () => (profileEmojis.length > 0 ? buildEmojiMap(profileEmojis) : new Map()), + [profileEmojis] ); useClickOutside(scrollRef, isTopmost, onClose); diff --git a/src/ui/utils/htmlSanitizer.ts b/src/ui/utils/htmlSanitizer.ts index 15f88f3..190729f 100644 --- a/src/ui/utils/htmlSanitizer.ts +++ b/src/ui/utils/htmlSanitizer.ts @@ -5,25 +5,29 @@ import DOMPurify from 'dompurify'; * Allows basic formatting tags for rich content display */ export const sanitizeHtml = (html: string): string => { - // Pre-process HTML to add target="_blank" to all external links - const processedHtml = html.replace(/]*)href="([^"]*)"([^>]*)>/gi, (match, attrs, href, rest) => { - // Check if it's an external link (starts with http) - if (href.startsWith('http://') || href.startsWith('https://')) { - // Add target="_blank" and rel="noreferrer" if not already present - const hasTarget = /target\s*=\s*["'][^"']*["']/.test(attrs + rest); - const hasRel = /rel\s*=\s*["'][^"']*["']/.test(attrs + rest); - - let newAttrs = attrs + rest; - if (!hasTarget) { - newAttrs += ' target="_blank"'; - } - if (!hasRel) { - newAttrs += ' rel="noreferrer"'; - } - return ``; + const processedHtml = (() => { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + doc.querySelectorAll("a[href]").forEach((anchor) => { + const href = anchor.getAttribute("href"); + if (!href) { + return; + } + if (href.startsWith("http://") || href.startsWith("https://")) { + if (!anchor.hasAttribute("target")) { + anchor.setAttribute("target", "_blank"); + } + if (!anchor.hasAttribute("rel")) { + anchor.setAttribute("rel", "noreferrer"); + } + } + }); + return doc.body.innerHTML; + } catch { + return html; } - return match; - }); + })(); return DOMPurify.sanitize(processedHtml, { ALLOWED_TAGS: [ @@ -34,4 +38,4 @@ export const sanitizeHtml = (html: string): string => { FORBID_ATTR: ['onclick', 'onload', 'onerror', 'onmouseover'], ALLOW_DATA_ATTR: false, }); -}; \ No newline at end of file +}; diff --git a/src/ui/utils/linkify.ts b/src/ui/utils/linkify.ts index b3b3823..5df43db 100644 --- a/src/ui/utils/linkify.ts +++ b/src/ui/utils/linkify.ts @@ -87,17 +87,25 @@ export const renderTextWithLinks = ( } else { parts.push(matched); } - } else if (matched.includes("@")) { - parts.push(matched); } else { - const normalizedUrl = normalizeUrl(matched); - parts.push( - React.createElement( - "a", - { key: `${keyPrefix}-link-${key}`, href: normalizedUrl, target: "_blank", rel: "noreferrer" }, - matched - ) - ); + const looksLikeUrlWithAt = + matched.includes("@") && + (matched.startsWith("http://") || + matched.startsWith("https://") || + matched.startsWith("www.") || + matched.includes("/")); + if (matched.includes("@") && !looksLikeUrlWithAt) { + parts.push(matched); + } else { + const normalizedUrl = normalizeUrl(matched); + parts.push( + React.createElement( + "a", + { key: `${keyPrefix}-link-${key}`, href: normalizedUrl, target: "_blank", rel: "noreferrer" }, + matched + ) + ); + } } key += 1; lastIndex = match.index + matched.length; From b78712e3848c7d4da52a2b6a113ddb37fff28e5a Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Mon, 12 Jan 2026 13:08:17 +0900 Subject: [PATCH 02/28] =?UTF-8?q?feat:=20=ED=91=9C=EC=A4=80=20=EC=9D=B4?= =?UTF-8?q?=EB=AA=A8=EC=A7=80=20=ED=8C=94=EB=A0=9B=ED=8A=B8=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 4 +- package.json | 1 + src/ui/components/ComposeBox.tsx | 148 ++++++++++++++++--- src/ui/components/ReactionPicker.tsx | 147 ++++++++++++++++--- src/ui/hooks/useEmojiManager.ts | 205 ++++++++++++++++++++++++--- src/ui/styles/components.css | 23 +++ 6 files changed, 466 insertions(+), 62 deletions(-) diff --git a/bun.lock b/bun.lock index bfd6e79..ef55fb4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,11 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "textodon", "dependencies": { "dompurify": "^3.3.1", + "emoji-datasource": "^16.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "wrangler": "3.90.0", @@ -282,6 +282,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], + "emoji-datasource": ["emoji-datasource@16.0.0", "", {}, "sha512-/qHKqK5Nr3+8zhgO6kHmF43Fm5C8HNn0AaFRIpgw8HF3+uF0Vfc8jgLI1ZQS5ba1vBzksS8NBCjHejwLb2D/Sg=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], diff --git a/package.json b/package.json index a8b1eeb..8908afa 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "dompurify": "^3.3.1", + "emoji-datasource": "^16.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "wrangler": "3.90.0" diff --git a/src/ui/components/ComposeBox.tsx b/src/ui/components/ComposeBox.tsx index 2d0f46b..f4f602f 100644 --- a/src/ui/components/ComposeBox.tsx +++ b/src/ui/components/ComposeBox.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Account, Visibility } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; -import { useEmojiManager } from "../hooks/useEmojiManager"; +import { useEmojiManager, type EmojiItem } from "../hooks/useEmojiManager"; import { useImageZoom } from "../hooks/useImageZoom"; import { calculateCharacterCount, @@ -88,6 +88,8 @@ export const ComposeBox = ({ emojiStatus, emojiError, emojiCategories, + customEmojiCategories, + standardEmojiCategories, expandedCategories, loadEmojis, addToRecent, @@ -268,8 +270,7 @@ export const ComposeBox = ({ }); }; - const insertEmoji = (shortcode: string) => { - const value = `${ZERO_WIDTH_SPACE}:${shortcode}:${ZERO_WIDTH_SPACE}`; + const insertEmojiValue = (value: string) => { const textarea = textareaRef.current; if (!textarea) { setText((current) => `${current}${value}`); @@ -286,9 +287,23 @@ export const ComposeBox = ({ }); }; - const handleEmojiSelect = (emoji) => { - insertEmoji(emoji.shortcode); - addToRecent(emoji.shortcode); + const buildEmojiInsertValue = (emoji: EmojiItem) => { + if (emoji.isCustom && emoji.shortcode) { + return `${ZERO_WIDTH_SPACE}:${emoji.shortcode}:${ZERO_WIDTH_SPACE}`; + } + if (emoji.unicode) { + return `${ZERO_WIDTH_SPACE}${emoji.unicode}${ZERO_WIDTH_SPACE}`; + } + return ""; + }; + + const handleEmojiSelect = (emoji: EmojiItem) => { + const value = buildEmojiInsertValue(emoji); + if (!value) { + return; + } + insertEmojiValue(value); + addToRecent(emoji.id); }; const handleToggleCategory = (categoryId: string) => { @@ -420,7 +435,7 @@ export const ComposeBox = ({ ) : null} - {account && emojiStatus === "loaded" && emojiCategories.length === 0 ? ( -

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

+ {account && emojiCategories.length === 0 ? ( +

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

) : null} - {account && emojiStatus === "loaded" - ? emojiCategories.map((category) => { + {account && emojiCategories.length > 0 ? ( + <> + {(() => { + const recentCategory = emojiCategories.find((item) => item.id === "recent"); + if (!recentCategory) return null; + const categoryKey = `${account.instanceUrl}::${recentCategory.id}`; + const isCollapsed = !recentOpen; + return ( +
+ + {isCollapsed ? null : ( +
+ {recentCategory.emojis.map((emoji) => ( + + ))} +
+ )} +
+ ); + })()} + {customEmojiCategories.map((category) => { const categoryKey = `${account.instanceUrl}::${category.id}`; - const isCollapsed = - category.id === "recent" ? !recentOpen : !expandedCategories.has(category.id); + const isCollapsed = !expandedCategories.has(category.id); return (
))} )}
); - }) - : null} + })} + {customEmojiCategories.length > 0 && standardEmojiCategories.length > 0 ? ( +
+ 표준 이모지 +
+ ) : null} + {standardEmojiCategories.map((category) => { + const categoryKey = `${account.instanceUrl}::${category.id}`; + const isCollapsed = !expandedCategories.has(category.id); + return ( +
+ + {isCollapsed ? null : ( +
+ {category.emojis.map((emoji) => ( + + ))} +
+ )} +
+ ); + })} + + ) : null} + ) : null} diff --git a/src/ui/components/ReactionPicker.tsx b/src/ui/components/ReactionPicker.tsx index 0c99f85..afd70eb 100644 --- a/src/ui/components/ReactionPicker.tsx +++ b/src/ui/components/ReactionPicker.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import type { Account, ReactionInput } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; import { useClickOutside } from "../hooks/useClickOutside"; -import { useEmojiManager } from "../hooks/useEmojiManager"; +import { useEmojiManager, type EmojiItem } from "../hooks/useEmojiManager"; export const ReactionPicker = ({ account, @@ -27,6 +27,8 @@ export const ReactionPicker = ({ emojiStatus, emojiError, emojiCategories, + customEmojiCategories, + standardEmojiCategories, expandedCategories, loadEmojis, addToRecent, @@ -90,14 +92,25 @@ export const ReactionPicker = ({ }, [open, emojis.length]); const handleSelect = useCallback( - (emoji) => { - onSelect({ - name: `:${emoji.shortcode}:`, - url: emoji.url, - isCustom: true, - host: null - }); - addToRecent(emoji.shortcode); + (emoji: EmojiItem) => { + if (emoji.isCustom && emoji.shortcode) { + onSelect({ + name: `:${emoji.shortcode}:`, + url: emoji.url ?? null, + isCustom: true, + host: null + }); + } else if (emoji.unicode) { + onSelect({ + name: emoji.unicode, + url: null, + isCustom: false, + host: null + }); + } else { + return; + } + addToRecent(emoji.id); setOpen(false); }, [onSelect, addToRecent] @@ -149,14 +162,101 @@ export const ReactionPicker = ({ ) : null} - {account && emojiStatus === "loaded" && emojiCategories.length === 0 ? ( -

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

+ {account && emojiCategories.length === 0 ? ( +

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

) : null} - {account && emojiStatus === "loaded" - ? emojiCategories.map((category) => { + {account && emojiCategories.length > 0 ? ( + <> + {(() => { + const recentCategory = emojiCategories.find((item) => item.id === "recent"); + if (!recentCategory) return null; + const categoryKey = `${account.instanceUrl}::${recentCategory.id}`; + const isCollapsed = !recentOpen; + return ( +
+ + {isCollapsed ? null : ( +
+ {recentCategory.emojis.map((emoji) => ( + + ))} +
+ )} +
+ ); + })()} + {customEmojiCategories.map((category) => { + const categoryKey = `${account.instanceUrl}::${category.id}`; + const isCollapsed = !expandedCategories.has(category.id); + return ( +
+ + {isCollapsed ? null : ( +
+ {category.emojis.map((emoji) => ( + + ))} +
+ )} +
+ ); + })} + {customEmojiCategories.length > 0 && standardEmojiCategories.length > 0 ? ( +
+ 표준 이모지 +
+ ) : null} + {standardEmojiCategories.map((category) => { const categoryKey = `${account.instanceUrl}::${category.id}`; - const isCollapsed = - category.id === "recent" ? !recentOpen : !expandedCategories.has(category.id); + const isCollapsed = !expandedCategories.has(category.id); return (
))} )}
); - }) - : null} + })} + + ) : null} diff --git a/src/ui/hooks/useEmojiManager.ts b/src/ui/hooks/useEmojiManager.ts index 4186c53..47d5f7d 100644 --- a/src/ui/hooks/useEmojiManager.ts +++ b/src/ui/hooks/useEmojiManager.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import emojiData from "emoji-datasource/emoji.json"; import type { Account, CustomEmoji } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; import { getCachedEmojis, setCachedEmojis } from "../utils/emojiCache"; @@ -6,9 +7,140 @@ import { getCachedEmojis, setCachedEmojis } from "../utils/emojiCache"; const RECENT_EMOJI_KEY_PREFIX = "textodon.compose.recentEmojis."; const RECENT_EMOJI_LIMIT = 24; +type EmojiDatasetEntry = { + unified: string; + name: string; + short_name: string; + category?: string; + has_img_apple?: boolean; + skin_variations?: Record; +}; + +export type EmojiItem = { + id: string; + label: string; + isCustom: boolean; + category?: string | null; + shortcode?: string; + url?: string; + unicode?: string; +}; + +export type EmojiCategory = { + id: string; + label: string; + emojis: EmojiItem[]; +}; + +const STANDARD_CATEGORY_LABELS: Record = { + "Smileys & Emotion": "표정", + "People & Body": "사람/손", + "Animals & Nature": "동물/자연", + "Food & Drink": "음식", + "Travel & Places": "여행/장소", + Activities: "활동", + Objects: "사물", + Symbols: "기호", + Flags: "국기" +}; + +const STANDARD_CATEGORY_ORDER = [ + "Smileys & Emotion", + "People & Body", + "Animals & Nature", + "Food & Drink", + "Travel & Places", + "Activities", + "Objects", + "Symbols", + "Flags" +]; + +const unicodeFromUnified = (value: string) => { + if (!value) { + return ""; + } + return value + .split("-") + .map((code) => String.fromCodePoint(Number.parseInt(code, 16))) + .join(""); +}; + +const buildStandardEmojiCategories = () => { + const grouped = new Map(); + const seen = new Set(); + + const addEmoji = (unified: string, categoryKey: string) => { + const unicode = unicodeFromUnified(unified); + if (!unicode) { + return; + } + const id = `unicode:${unicode}`; + if (seen.has(id)) { + return; + } + seen.add(id); + const label = STANDARD_CATEGORY_LABELS[categoryKey] ?? "기타"; + const list = grouped.get(label) ?? []; + list.push({ + id, + label: unicode, + unicode, + isCustom: false, + category: label + }); + grouped.set(label, list); + }; + + const emojiDataset = emojiData as EmojiDatasetEntry[]; + emojiDataset.forEach((emoji) => { + if (!emoji.unified) { + return; + } + if (emoji.has_img_apple === false) { + return; + } + const categoryKey = emoji.category ?? "기타"; + addEmoji(emoji.unified, categoryKey); + if (emoji.skin_variations) { + Object.values(emoji.skin_variations).forEach((variation) => { + if (variation?.unified) { + addEmoji(variation.unified, categoryKey); + } + }); + } + }); + + const orderedLabels = STANDARD_CATEGORY_ORDER.map( + (category) => STANDARD_CATEGORY_LABELS[category] + ).filter(Boolean); + const categories: EmojiCategory[] = []; + orderedLabels.forEach((label) => { + const emojis = grouped.get(label); + if (emojis && emojis.length > 0) { + categories.push({ id: `standard:${label}`, label, emojis }); + } + }); + const remaining = Array.from(grouped.entries()) + .filter(([label]) => !orderedLabels.includes(label)) + .sort(([a], [b]) => a.localeCompare(b, "ko-KR")) + .map(([label, emojis]) => ({ id: `standard:${label}`, label, emojis })); + + return [...categories, ...remaining]; +}; + +const STANDARD_EMOJI_CATEGORIES = buildStandardEmojiCategories(); + const buildRecentEmojiKey = (instanceUrl: string) => `${RECENT_EMOJI_KEY_PREFIX}${encodeURIComponent(instanceUrl)}`; +const normalizeRecentEmojiId = (value: string) => { + if (value.startsWith("custom:") || value.startsWith("unicode:")) { + return value; + } + return `custom:${value}`; +}; + const loadRecentEmojis = (instanceUrl: string): string[] => { try { const stored = localStorage.getItem(buildRecentEmojiKey(instanceUrl)); @@ -19,7 +151,9 @@ const loadRecentEmojis = (instanceUrl: string): string[] => { if (!Array.isArray(parsed)) { return []; } - return parsed.filter((item) => typeof item === "string"); + return parsed + .filter((item) => typeof item === "string") + .map((item) => normalizeRecentEmojiId(item as string)); } catch { return []; } @@ -33,12 +167,6 @@ const persistRecentEmojis = (instanceUrl: string, list: string[]) => { } }; -export type EmojiCategory = { - id: string; - label: string; - emojis: CustomEmoji[]; -}; - /** * 이모지 카탈로그, 최근 사용 이모지, 카테고리화를 관리하는 커스텀 훅 * @@ -77,25 +205,55 @@ export const useEmojiManager = ( const emojiStatus = instanceUrl ? emojiLoadState[instanceUrl] ?? "idle" : "idle"; const emojiError = instanceUrl ? emojiErrors[instanceUrl] ?? null : null; - // 최근 사용한 이모지 shortcode 목록 - const recentShortcodes = instanceUrl ? recentByInstance[instanceUrl] ?? [] : []; + // 최근 사용한 이모지 id 목록 + const recentIds = instanceUrl ? recentByInstance[instanceUrl] ?? [] : []; + + const standardEmojiCategories = STANDARD_EMOJI_CATEGORIES; - // shortcode → emoji 맵핑 + const standardEmojiItems = useMemo( + () => standardEmojiCategories.flatMap((category) => category.emojis), + [standardEmojiCategories] + ); + + const customEmojiItems = useMemo( + () => + activeEmojis.map((emoji) => ({ + id: `custom:${emoji.shortcode}`, + label: emoji.shortcode, + shortcode: emoji.shortcode, + url: emoji.url, + category: emoji.category?.trim() || "기타", + isCustom: true + })), + [activeEmojis] + ); + + const allEmojis = useMemo( + () => [...standardEmojiItems, ...customEmojiItems], + [standardEmojiItems, customEmojiItems] + ); + + // shortcode → emoji 맵핑 (커스텀 전용) const emojiMap = useMemo( () => new Map(activeEmojis.map((emoji) => [emoji.shortcode, emoji])), [activeEmojis] ); + const emojiCatalogMap = useMemo( + () => new Map(allEmojis.map((emoji) => [emoji.id, emoji])), + [allEmojis] + ); + // 최근 사용한 이모지 객체 목록 const recentEmojis = useMemo( - () => recentShortcodes.map((shortcode) => emojiMap.get(shortcode)).filter(Boolean) as CustomEmoji[], - [emojiMap, recentShortcodes] + () => recentIds.map((id) => emojiCatalogMap.get(id)).filter(Boolean) as EmojiItem[], + [emojiCatalogMap, recentIds] ); - // 카테고리별로 그룹화된 이모지 + // 카테고리별로 그룹화된 커스텀 이모지 const categorizedEmojis = useMemo(() => { - const grouped = new Map(); - activeEmojis.forEach((emoji) => { + const grouped = new Map(); + customEmojiItems.forEach((emoji) => { const category = emoji.category?.trim() || "기타"; const list = grouped.get(category) ?? []; list.push(emoji); @@ -104,16 +262,16 @@ export const useEmojiManager = ( return Array.from(grouped.entries()) .sort(([a], [b]) => a.localeCompare(b, "ko-KR")) .map(([label, emojis]) => ({ id: `category:${label}`, label, emojis })); - }, [activeEmojis]); + }, [customEmojiItems]); // 최근 사용 카테고리를 포함한 전체 카테고리 목록 const emojiCategories = useMemo(() => { - const categories = [...categorizedEmojis]; + const categories = [...categorizedEmojis, ...standardEmojiCategories]; if (recentEmojis.length > 0) { categories.unshift({ id: "recent", label: "최근 사용", emojis: recentEmojis }); } return categories; - }, [categorizedEmojis, recentEmojis]); + }, [standardEmojiCategories, categorizedEmojis, recentEmojis]); // 현재 인스턴스에서 확장된 카테고리 Set const expandedCategories = instanceUrl ? expandedByInstance[instanceUrl] ?? new Set() : new Set(); @@ -187,13 +345,14 @@ export const useEmojiManager = ( // 이모지를 최근 사용 목록에 추가 const addToRecent = useCallback( - (shortcode: string) => { + (emojiId: string) => { if (!instanceUrl) return; + const normalizedId = normalizeRecentEmojiId(emojiId); setRecentByInstance((current) => { const existing = current[instanceUrl] ?? []; - const filtered = existing.filter((code) => code !== shortcode); - const nextList = [shortcode, ...filtered].slice(0, RECENT_EMOJI_LIMIT); + const filtered = existing.filter((code) => code !== normalizedId); + const nextList = [normalizedId, ...filtered].slice(0, RECENT_EMOJI_LIMIT); persistRecentEmojis(instanceUrl, nextList); return { ...current, [instanceUrl]: nextList }; }); @@ -222,11 +381,13 @@ export const useEmojiManager = ( return { // 상태 - emojis: activeEmojis, + emojis: allEmojis, emojiStatus, emojiError, recentEmojis, categorizedEmojis, + standardEmojiCategories, + customEmojiCategories: categorizedEmojis, emojiCategories, expandedCategories, emojiMap, diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index f4dc9f4..3bfea10 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -706,6 +706,29 @@ object-fit: contain; } +.compose-emoji-text { + font-size: 20px; + line-height: 1; +} + +.compose-emoji-divider { + display: flex; + align-items: center; + gap: 8px; + color: var(--color-emoji-empty-text); + font-size: 12px; + margin: 4px 0; +} + +.compose-emoji-divider::before, +.compose-emoji-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--color-emoji-panel-border); + opacity: 0.6; +} + button { font-family: inherit; background: var(--color-action-bg); From 01fe12a3f947d0ed329068b49ff2dc8f5ba4c567 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Mon, 12 Jan 2026 13:34:54 +0900 Subject: [PATCH 03/28] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=B0=8F=20=EC=B6=94=EC=B2=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/components/ComposeBox.tsx | 222 ++++++++++++++++++++++++++- src/ui/components/ReactionPicker.tsx | 65 +++++++- src/ui/hooks/useEmojiManager.ts | 32 +++- src/ui/styles/components.css | 59 +++++++ 4 files changed, 368 insertions(+), 10 deletions(-) diff --git a/src/ui/components/ComposeBox.tsx b/src/ui/components/ComposeBox.tsx index f4f602f..7a4ab82 100644 --- a/src/ui/components/ComposeBox.tsx +++ b/src/ui/components/ComposeBox.tsx @@ -46,6 +46,13 @@ export const ComposeBox = ({ api: MastodonApi; }) => { const [text, setText] = useState(""); + const [emojiQuery, setEmojiQuery] = useState<{ + value: string; + start: number; + end: number; + } | null>(null); + const [emojiSuggestionIndex, setEmojiSuggestionIndex] = useState(0); + const [emojiSearchQuery, setEmojiSearchQuery] = useState(""); const [cwEnabled, setCwEnabled] = useState(false); const [cwText, setCwText] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -93,7 +100,8 @@ export const ComposeBox = ({ expandedCategories, loadEmojis, addToRecent, - toggleCategory + toggleCategory, + searchEmojis } = useEmojiManager(account, api, false); const activeImage = useMemo( @@ -101,6 +109,22 @@ export const ComposeBox = ({ [attachments, activeImageId] ); + const emojiSuggestions = useMemo(() => { + if (!emojiQuery) { + return []; + } + return searchEmojis(emojiQuery.value, 5); + }, [emojiQuery, searchEmojis]); + + const emojiSearchResults = useMemo(() => { + if (!emojiSearchQuery.trim()) { + return []; + } + return searchEmojis(emojiSearchQuery); + }, [emojiSearchQuery, searchEmojis]); + + const hasEmojiSearch = emojiSearchQuery.trim().length > 0; + useEffect(() => { if (!activeImage) { return; @@ -210,11 +234,28 @@ export const ComposeBox = ({ useEffect(() => { if (!emojiPanelOpen) { + setEmojiSearchQuery(""); return; } setRecentOpen(true); }, [emojiPanelOpen]); + useEffect(() => { + setEmojiSuggestionIndex(0); + }, [emojiQuery?.value, emojiSuggestions.length]); + + useEffect(() => { + if (emojiQuery?.value && account && emojiStatus === "idle") { + void loadEmojis(); + } + }, [emojiQuery?.value, account, emojiStatus, loadEmojis]); + + useEffect(() => { + if (emojiSearchQuery.trim() && account && emojiStatus === "idle") { + void loadEmojis(); + } + }, [emojiSearchQuery, account, emojiStatus, loadEmojis]); + const addAttachments = useCallback((files: File[]) => { if (files.length === 0) { return; @@ -270,6 +311,44 @@ export const ComposeBox = ({ }); }; + const findEmojiQuery = useCallback( + (value: string, cursor: number) => { + if (cursor <= 0) { + return null; + } + const beforeCursor = value.slice(0, cursor); + const colonIndex = beforeCursor.lastIndexOf(":"); + if (colonIndex < 0) { + return null; + } + const query = beforeCursor.slice(colonIndex + 1); + if (!query || /\s/.test(query)) { + return null; + } + const prevChar = colonIndex > 0 ? beforeCursor[colonIndex - 1] : ""; + if (prevChar && !/\s/.test(prevChar) && prevChar !== ZERO_WIDTH_SPACE) { + return null; + } + return { + value: query, + start: colonIndex, + end: cursor + }; + }, + [] + ); + + const updateEmojiQuery = useCallback( + (value: string, cursor: number) => { + const nextQuery = findEmojiQuery(value, cursor); + setEmojiQuery(nextQuery); + if (!nextQuery) { + setEmojiSuggestionIndex(0); + } + }, + [findEmojiQuery] + ); + const insertEmojiValue = (value: string) => { const textarea = textareaRef.current; if (!textarea) { @@ -297,6 +376,31 @@ export const ComposeBox = ({ return ""; }; + const handleEmojiSuggestionSelect = (emoji: EmojiItem) => { + if (!emojiQuery) { + return; + } + const value = buildEmojiInsertValue(emoji); + if (!value) { + return; + } + const nextText = `${text.slice(0, emojiQuery.start)}${value}${text.slice(emojiQuery.end)}`; + setText(nextText); + addToRecent(emoji.id); + setEmojiQuery(null); + setEmojiSuggestionIndex(0); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + const nextCursor = emojiQuery.start + value.length; + textarea.focus(); + textarea.setSelectionRange(nextCursor, nextCursor); + updateEmojiQuery(nextText, nextCursor); + }); + }; + const handleEmojiSelect = (emoji: EmojiItem) => { const value = buildEmojiInsertValue(emoji); if (!value) { @@ -361,18 +465,89 @@ export const ComposeBox = ({