From ee4e67bb74499c586202ff56f99785f8d8ed82ef Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Fri, 9 Jan 2026 13:32:38 +0900 Subject: [PATCH 01/13] =?UTF-8?q?docs:=20AGENTS.md=EC=97=90=EC=84=9C=20CLA?= =?UTF-8?q?UDE.MD=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code가 읽을 수 있도록 AI 에이전트 가이드 파일을 CLAUDE.MD로 생성했습니다. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.MD | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 CLAUDE.MD diff --git a/CLAUDE.MD b/CLAUDE.MD new file mode 100644 index 0000000..6a95ede --- /dev/null +++ b/CLAUDE.MD @@ -0,0 +1,27 @@ +# CLAUDE.MD + +## 기본 원칙 +- SOLID 원칙을 준수한다. +- 패키지 매니저는 Bun으로 통일하고, `bun.lock`을 항상 커밋한다. +- tsconfig는 `strict` 중심으로 유지하고, `any`/`as` 남용을 피한다. +- React 컴포넌트는 props 타입을 명확히 하고, 상태는 최소화한다. +- 비동기 상태 갱신은 낙관적 업데이트와 실패 롤백을 고려한다. +- 스타일은 목적별 파일로 분리하고, 전역 스타일은 최소화한다. +- UI의 색상 변경 시 모든 테마의 라이트/다크 모드를 모두 고려하여 변경한다. +- 사용하는 텍스트는 한국어를 기본으로 사용하고, UTF-8 인코딩을 적용한다. +- 다른 컨텐츠 위에 뜨는 메뉴나 팝오버, 팝업들은 자신 이외의 영역을 클릭했을 때 닫혀야 하며 배경색도 틴트 처리가 되어야 한다. +- 접근성: 버튼/아이콘에 `aria-label`, 텍스트 대체를 제공한다. +- 배포 용어: Cloudflare Pages 배포는 production, GitHub Pages 배포는 beta로 칭한다. + +## 작업 플로우 +- 작업 시작 전: `develop` 최신화 → 새 feature 브랜치 생성. +- 새로운 작업은 항상 `develop` 최신화 후 `feature/{기능-이름}` 브랜치에서 시작한다. +- 브랜치 이름은 작업 내용에 맞게 스스로 정한다. +- 브랜치 변경 시 미커밋 변경사항은 스태시 후 새 브랜치에 다시 적용하는 흐름을 우선한다. +- 작업 종료(릴리즈 준비 요청): 커밋 → 푸시 → PR 생성까지 진행한다. +- 브랜치 전략: `develop`은 beta 배포 기준 브랜치, `main`은 production 배포 기준 브랜치로 사용한다. + +## PR 작성 규칙 +- PR 본문은 마크다운이 깨지지 않도록 멀티라인(heredoc) 방식으로 작성한다. +- PR 제목/본문의 한글 인코딩이 깨지지 않도록 UTF-8로 작성·확인한다. +- PR URL은 코드 블록 없이 클릭 가능한 일반 텍스트로 제공한다. From f46b130546c0af57e13136e072e563b22087985d Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Fri, 9 Jan 2026 13:56:36 +0900 Subject: [PATCH 02/13] =?UTF-8?q?refactor:=20=ED=8C=9D=EC=98=A4=EB=B2=84/?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=99=B8=EB=B6=80=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20useClickOutside=20=ED=9B=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useClickOutside 커스텀 훅 생성 (외부 클릭 및 ESC 키 처리) - 중복된 useEffect 로직 153줄 제거 (-88%) - 5개 컴포넌트에 적용: App(3개), TimelineItem, AccountSelector, ReactionPicker - ref를 메뉴 패널에만 연결하여 버튼 토글 정상 동작 보장 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/App.tsx | 105 ++++---------------------- src/ui/components/AccountSelector.tsx | 30 +------- src/ui/components/ReactionPicker.tsx | 32 +------- src/ui/components/TimelineItem.tsx | 36 ++------- src/ui/hooks/useClickOutside.ts | 59 +++++++++++++++ 5 files changed, 84 insertions(+), 178 deletions(-) create mode 100644 src/ui/hooks/useClickOutside.ts diff --git a/src/App.tsx b/src/App.tsx index fd1a32f..1149671 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { ComposeBox } from "./ui/components/ComposeBox"; import { StatusModal } from "./ui/components/StatusModal"; import { TimelineItem } from "./ui/components/TimelineItem"; import { useTimeline } from "./ui/hooks/useTimeline"; +import { useClickOutside } from "./ui/hooks/useClickOutside"; import { useAppContext } from "./ui/state/AppContext"; import type { AccountsState, AppServices } from "./ui/state/AppContext"; import { createAccountId, formatHandle } from "./ui/utils/account"; @@ -349,80 +350,11 @@ const TimelineSection = ({ }; }, [account, registerTimelineListener, timeline.updateItem, timelineType, unregisterTimelineListener]); - useEffect(() => { - if (!menuOpen) { - return; - } - const handleClick = (event: MouseEvent) => { - if (!menuRef.current || !(event.target instanceof Node)) { - return; - } - if ( - event.target instanceof Element && - event.target.closest(".overlay-backdrop") - ) { - setMenuOpen(false); - return; - } - if (!menuRef.current.contains(event.target)) { - setMenuOpen(false); - } - }; - document.addEventListener("mousedown", handleClick); - return () => { - document.removeEventListener("mousedown", handleClick); - }; - }, [menuOpen]); + useClickOutside(menuRef, menuOpen, () => setMenuOpen(false)); - useEffect(() => { - if (!timelineMenuOpen) { - return; - } - const handleClick = (event: MouseEvent) => { - if (!timelineMenuRef.current || !(event.target instanceof Node)) { - return; - } - if ( - event.target instanceof Element && - event.target.closest(".overlay-backdrop") - ) { - setTimelineMenuOpen(false); - return; - } - if (!timelineMenuRef.current.contains(event.target)) { - setTimelineMenuOpen(false); - } - }; - document.addEventListener("mousedown", handleClick); - return () => { - document.removeEventListener("mousedown", handleClick); - }; - }, [timelineMenuOpen]); + useClickOutside(timelineMenuRef, timelineMenuOpen, () => setTimelineMenuOpen(false)); - useEffect(() => { - if (!notificationsOpen) { - return; - } - const handleClick = (event: MouseEvent) => { - if (!notificationMenuRef.current || !(event.target instanceof Node)) { - return; - } - if ( - event.target instanceof Element && - event.target.closest(".overlay-backdrop") - ) { - setNotificationsOpen(false); - return; - } - if (!notificationMenuRef.current.contains(event.target)) { - setNotificationsOpen(false); - } - }; - document.addEventListener("mousedown", handleClick); - return () => { - document.removeEventListener("mousedown", handleClick); - }; - }, [notificationsOpen]); + useClickOutside(notificationMenuRef, notificationsOpen, () => setNotificationsOpen(false)); useEffect(() => { if (!notificationsOpen) { @@ -542,7 +474,7 @@ const TimelineSection = ({ variant="inline" />
-
+
-
+
{open ? ( <> -
setOpen(false)} aria-hidden="true" /> + -
+
diff --git a/src/ui/hooks/useClickOutside.ts b/src/ui/hooks/useClickOutside.ts new file mode 100644 index 0000000..96f3fe2 --- /dev/null +++ b/src/ui/hooks/useClickOutside.ts @@ -0,0 +1,59 @@ +import { useEffect, type RefObject } from "react"; + +/** + * 외부 클릭 및 ESC 키 입력 시 콜백을 실행하는 커스텀 훅 + * + * @param ref - 대상 요소의 ref (이 요소 외부 클릭 시 콜백 실행) + * @param isOpen - 활성화 여부 (true일 때만 이벤트 리스너 등록) + * @param onClose - 외부 클릭 또는 ESC 키 입력 시 실행할 콜백 + * @param ignoreRefs - 클릭을 무시할 추가 요소들의 ref 배열 (예: 버튼) + */ +export const useClickOutside = ( + ref: RefObject, + isOpen: boolean, + onClose: () => void, + ignoreRefs?: RefObject[] +) => { + useEffect(() => { + if (!isOpen) { + return; + } + + const handleClick = (event: MouseEvent) => { + if (!ref.current || !(event.target instanceof Node)) { + return; + } + + // 대상 요소 내부 클릭인지 확인 + if (ref.current.contains(event.target)) { + return; + } + + // 무시할 요소들 확인 + if (ignoreRefs) { + for (const ignoreRef of ignoreRefs) { + if (ignoreRef.current?.contains(event.target)) { + return; + } + } + } + + onClose(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + // mousedown은 클릭이 시작될 때 발생 (click보다 먼저) + document.addEventListener("mousedown", handleClick); + window.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handleClick); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose, ref, ignoreRefs]); +}; From b74190d82f3ae6b568735fa868ab37cc6b4c9c13 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Fri, 9 Jan 2026 14:05:34 +0900 Subject: [PATCH 03/13] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AA=A8=EC=A7=80?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=EC=9D=84=20useEmojiM?= =?UTF-8?q?anager=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useEmojiManager 커스텀 훅 생성 (이모지 로드, 최근 사용, 카테고리화) - 중복된 이모지 관리 로직 297줄 제거 (-69%) - ComposeBox와 ReactionPicker에 적용 - 인스턴스별 이모지 카탈로그, 최근 사용 이모지, 카테고리 확장 상태 관리 통합 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/ui/components/ComposeBox.tsx | 205 ++++------------------- src/ui/components/ReactionPicker.tsx | 224 ++++--------------------- src/ui/hooks/useEmojiManager.ts | 239 +++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 363 deletions(-) create mode 100644 src/ui/hooks/useEmojiManager.ts diff --git a/src/ui/components/ComposeBox.tsx b/src/ui/components/ComposeBox.tsx index 9517723..155fade 100644 --- a/src/ui/components/ComposeBox.tsx +++ b/src/ui/components/ComposeBox.tsx @@ -1,13 +1,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Account, CustomEmoji, Visibility } from "../../domain/types"; +import type { Account, Visibility } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; -import { getCachedEmojis, setCachedEmojis } from "../utils/emojiCache"; -import { - calculateCharacterCount, - getCharacterLimit, - getCharacterCountStatus, +import { useEmojiManager } from "../hooks/useEmojiManager"; +import { + calculateCharacterCount, + getCharacterLimit, + getCharacterCountStatus, getCharacterCountClassName, - getDefaultCharacterLimit + getDefaultCharacterLimit } from "../utils/characterCount"; const VISIBILITY_KEY = "textodon.compose.visibility"; @@ -19,37 +19,8 @@ const visibilityOptions: { value: Visibility; label: string }[] = [ { value: "direct", label: "DM" } ]; -const RECENT_EMOJI_KEY_PREFIX = "textodon.compose.recentEmojis."; -const RECENT_EMOJI_LIMIT = 24; const ZERO_WIDTH_SPACE = "\u200b"; -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 ComposeBox = ({ onSubmit, replyingTo, @@ -100,54 +71,28 @@ export const ComposeBox = ({ null ); const [emojiPanelOpen, setEmojiPanelOpen] = useState(false); - const [emojiCatalogs, setEmojiCatalogs] = useState>({}); - const [emojiLoadState, setEmojiLoadState] = useState< - Record - >({}); - const [emojiErrors, setEmojiErrors] = useState>({}); - const [recentByInstance, setRecentByInstance] = useState>({}); - const [expandedByInstance, setExpandedByInstance] = useState>>({}); const [recentOpen, setRecentOpen] = useState(true); - + // 문자 수 관련 상태 const [characterLimit, setCharacterLimit] = useState(null); const [instanceLoading, setInstanceLoading] = useState(false); + + // useEmojiManager 훅 사용 + const { + emojis: activeEmojis, + emojiStatus, + emojiError, + emojiCategories, + expandedCategories, + loadEmojis, + addToRecent, + toggleCategory + } = useEmojiManager(account, api, false); + const activeImage = useMemo( () => attachments.find((item) => item.id === activeImageId) ?? null, [attachments, activeImageId] ); - const activeInstanceUrl = account?.instanceUrl ?? null; - const activeEmojis = useMemo( - () => (activeInstanceUrl ? emojiCatalogs[activeInstanceUrl] ?? [] : []), - [activeInstanceUrl, emojiCatalogs] - ); - const emojiStatus = activeInstanceUrl ? emojiLoadState[activeInstanceUrl] ?? "idle" : "idle"; - const recentShortcodes = activeInstanceUrl ? recentByInstance[activeInstanceUrl] ?? [] : []; - const emojiMap = useMemo(() => new Map(activeEmojis.map((emoji) => [emoji.shortcode, emoji])), [activeEmojis]); - const recentEmojis = useMemo( - () => recentShortcodes.map((shortcode) => emojiMap.get(shortcode)).filter(Boolean) as CustomEmoji[], - [emojiMap, recentShortcodes] - ); - const categorizedEmojis = useMemo(() => { - const grouped = new Map(); - activeEmojis.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, emojis]) => ({ id: `category:${label}`, label, emojis })); - }, [activeEmojis]); - const emojiCategories = useMemo(() => { - const categories = [...categorizedEmojis]; - if (recentEmojis.length > 0) { - categories.unshift({ id: "recent", label: "최근 사용", emojis: recentEmojis }); - } - return categories; - }, [categorizedEmojis, recentEmojis]); - const expandedCategories = activeInstanceUrl ? expandedByInstance[activeInstanceUrl] ?? new Set() : new Set(); useEffect(() => { if (!activeImage) { @@ -297,65 +242,12 @@ export const ComposeBox = ({ } }, [mentionText]); + // 이모지 패널이 열리면 이모지 로드 useEffect(() => { - if (!activeInstanceUrl) { - return; + if (emojiPanelOpen && account) { + void loadEmojis(); } - 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; - } - return { ...current, [activeInstanceUrl]: loadRecentEmojis(activeInstanceUrl) }; - }); - setExpandedByInstance((current) => { - if (current[activeInstanceUrl]) { - return current; - } - return { ...current, [activeInstanceUrl]: new Set() }; - }); - }, [activeInstanceUrl]); - - useEffect(() => { - if (!emojiPanelOpen || !activeInstanceUrl || !account) { - return; - } - 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) { - setEmojiLoadState((current) => ({ ...current, [activeInstanceUrl]: "error" })); - setEmojiErrors((current) => ({ - ...current, - [activeInstanceUrl]: err instanceof Error ? err.message : "이모지를 불러오지 못했습니다." - })); - } - }; - void load(); - }, [account, activeInstanceUrl, api, emojiPanelOpen, emojiStatus]); + }, [emojiPanelOpen, account, loadEmojis]); useEffect(() => { if (!emojiPanelOpen) { @@ -437,37 +329,17 @@ export const ComposeBox = ({ }); }; - const handleEmojiSelect = (emoji: CustomEmoji) => { - if (!activeInstanceUrl) { - return; - } + const handleEmojiSelect = (emoji) => { insertEmoji(emoji.shortcode); - setRecentByInstance((current) => { - const currentList = current[activeInstanceUrl] ?? []; - const filtered = currentList.filter((item) => item !== emoji.shortcode); - const nextList = [emoji.shortcode, ...filtered].slice(0, RECENT_EMOJI_LIMIT); - persistRecentEmojis(activeInstanceUrl, nextList); - return { ...current, [activeInstanceUrl]: nextList }; - }); + addToRecent(emoji.shortcode); }; - const toggleCategory = (categoryId: string) => { - if (!activeInstanceUrl) { - return; - } + const handleToggleCategory = (categoryId: string) => { if (categoryId === "recent") { setRecentOpen((current) => !current); - return; + } else { + toggleCategory(categoryId); } - setExpandedByInstance((current) => { - const next = new Set(current[activeInstanceUrl] ?? []); - if (next.has(categoryId)) { - next.delete(categoryId); - } else { - next.add(categoryId); - } - return { ...current, [activeInstanceUrl]: next }; - }); }; const handleDeleteActive = () => { @@ -634,17 +506,8 @@ export const ComposeBox = ({ ) : null} {account && emojiStatus === "error" ? (
-

{emojiErrors[activeInstanceUrl ?? ""] ?? "이모지를 불러오지 못했습니다."}

-
@@ -654,7 +517,7 @@ export const ComposeBox = ({ ) : null} {account && emojiStatus === "loaded" ? emojiCategories.map((category) => { - const categoryKey = `${activeInstanceUrl ?? "unknown"}::${category.id}`; + const categoryKey = `${account.instanceUrl}::${category.id}`; const isCollapsed = category.id === "recent" ? !recentOpen : !expandedCategories.has(category.id); return ( @@ -662,7 +525,7 @@ export const ComposeBox = ({
) : null} - {account && emojiState === "loaded" && emojiCategories.length === 0 ? ( + {account && emojiStatus === "loaded" && emojiCategories.length === 0 ? (

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

) : null} - {account && emojiState === "loaded" + {account && emojiStatus === "loaded" ? emojiCategories.map((category) => { - const categoryKey = `${instanceUrl ?? "unknown"}::${category.id}`; + const categoryKey = `${account.instanceUrl}::${category.id}`; const isCollapsed = category.id === "recent" ? !recentOpen : !expandedCategories.has(category.id); return ( @@ -322,7 +162,7 @@ export const ReactionPicker = ({
diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index 1763071..a4d341d 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -7,6 +7,7 @@ import replyIconUrl from "../assets/reply-icon.svg"; import trashIconUrl from "../assets/trash-icon.svg"; import { ReactionPicker } from "./ReactionPicker"; import { useClickOutside } from "../hooks/useClickOutside"; +import { useImageZoom } from "../hooks/useImageZoom"; export const TimelineItem = ({ status, @@ -50,18 +51,22 @@ export const TimelineItem = ({ const boostedBy = notification ? null : status.reblog ? status.boostedBy : null; const [activeImageIndex, setActiveImageIndex] = useState(null); const [showContent, setShowContent] = useState(() => displayStatus.spoilerText.length === 0); - const [imageZoom, setImageZoom] = useState(1); - const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 }); - const [baseSize, setBaseSize] = useState<{ width: number; height: number } | null>(null); - const [isDragging, setIsDragging] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const imageContainerRef = useRef(null); const imageRef = useRef(null); const menuRef = useRef(null); - const dragStateRef = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>( - null - ); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + // useImageZoom 훅 사용 + const { + zoom: imageZoom, + offset: imageOffset, + isDragging, + handleWheel, + handleImageLoad, + handlePointerDown, + reset: resetImageZoom + } = useImageZoom(imageContainerRef, imageRef); const attachments = displayStatus.mediaAttachments; const activeImageUrl = activeImageIndex !== null ? attachments[activeImageIndex]?.url ?? null : null; @@ -69,17 +74,15 @@ export const TimelineItem = ({ if (activeImageIndex === null || attachments.length <= 1) return; const prevIndex = activeImageIndex === 0 ? attachments.length - 1 : activeImageIndex - 1; setActiveImageIndex(prevIndex); - setImageZoom(1); - setImageOffset({ x: 0, y: 0 }); - }, [activeImageIndex, attachments.length]); + resetImageZoom(); + }, [activeImageIndex, attachments.length, resetImageZoom]); const goToNextImage = useCallback(() => { if (activeImageIndex === null || attachments.length <= 1) return; const nextIndex = activeImageIndex === attachments.length - 1 ? 0 : activeImageIndex + 1; setActiveImageIndex(nextIndex); - setImageZoom(1); - setImageOffset({ x: 0, y: 0 }); - }, [activeImageIndex, attachments.length]); + resetImageZoom(); + }, [activeImageIndex, attachments.length, resetImageZoom]); useEffect(() => { if (activeImageIndex === null) return; @@ -537,53 +540,6 @@ export const TimelineItem = ({ }; }, [activeImageUrl]); - const clampOffset = useCallback( - (next: { x: number; y: number }, zoom: number) => { - if (!baseSize || !imageContainerRef.current) { - return next; - } - const container = imageContainerRef.current.getBoundingClientRect(); - const maxX = Math.max(0, (baseSize.width * zoom - container.width) / 2); - const maxY = Math.max(0, (baseSize.height * zoom - container.height) / 2); - return { - x: Math.min(maxX, Math.max(-maxX, next.x)), - y: Math.min(maxY, Math.max(-maxY, next.y)) - }; - }, - [baseSize] - ); - - useEffect(() => { - setImageOffset((current) => clampOffset(current, imageZoom)); - }, [imageZoom, clampOffset]); - - useEffect(() => { - if (!isDragging) { - return; - } - const handleMove = (event: PointerEvent) => { - if (!dragStateRef.current) { - return; - } - const dx = event.clientX - dragStateRef.current.startX; - const dy = event.clientY - dragStateRef.current.startY; - const next = { - x: dragStateRef.current.originX + dx, - y: dragStateRef.current.originY + dy - }; - setImageOffset(clampOffset(next, imageZoom)); - }; - const handleUp = () => { - setIsDragging(false); - dragStateRef.current = null; - }; - window.addEventListener("pointermove", handleMove); - window.addEventListener("pointerup", handleUp); - return () => { - window.removeEventListener("pointermove", handleMove); - window.removeEventListener("pointerup", handleUp); - }; - }, [clampOffset, imageZoom, isDragging]); const handleReactionSelect = useCallback( (reaction: ReactionInput) => { @@ -835,8 +791,7 @@ export const TimelineItem = ({ type="button" className="attachment-thumb" onClick={() => { - setImageZoom(1); - setImageOffset({ x: 0, y: 0 }); + resetImageZoom(); setActiveImageIndex(index); }} aria-label={item.description ? `이미지 보기: ${item.description}` : "이미지 보기"} @@ -964,35 +919,9 @@ export const TimelineItem = ({ style={{ transform: `scale(${imageZoom}) translate(${imageOffset.x / imageZoom}px, ${imageOffset.y / imageZoom}px)` }} - onWheel={(event) => { - event.preventDefault(); - const delta = event.deltaY > 0 ? -0.1 : 0.1; - setImageZoom((current) => Math.min(3, Math.max(0.6, current + delta))); - }} - onLoad={() => { - setImageZoom(1); - setImageOffset({ x: 0, y: 0 }); - requestAnimationFrame(() => { - if (!imageRef.current) { - return; - } - const rect = imageRef.current.getBoundingClientRect(); - setBaseSize({ width: rect.width, height: rect.height }); - }); - }} - onPointerDown={(event) => { - if (event.button !== 0) { - return; - } - event.preventDefault(); - dragStateRef.current = { - startX: event.clientX, - startY: event.clientY, - originX: imageOffset.x, - originY: imageOffset.y - }; - setIsDragging(true); - }} + onWheel={handleWheel} + onLoad={handleImageLoad} + onPointerDown={handlePointerDown} /> {attachments.length > 1 ? (
diff --git a/src/ui/hooks/useImageZoom.ts b/src/ui/hooks/useImageZoom.ts new file mode 100644 index 0000000..b74bc64 --- /dev/null +++ b/src/ui/hooks/useImageZoom.ts @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type ImageZoomState = { + zoom: number; + offset: { x: number; y: number }; + baseSize: { width: number; height: number } | null; + isDragging: boolean; +}; + +/** + * 이미지 확대/축소 및 드래그 이동을 관리하는 커스텀 훅 + * + * @param imageContainerRef - 이미지 컨테이너 요소의 ref + * @param imageRef - 이미지 요소의 ref + * @returns 이미지 줌 상태 및 이벤트 핸들러들 + */ +export const useImageZoom = ( + imageContainerRef: React.RefObject, + imageRef: React.RefObject +) => { + const [zoom, setZoom] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [baseSize, setBaseSize] = useState<{ width: number; height: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + const dragStateRef = useRef<{ + startX: number; + startY: number; + originX: number; + originY: number; + } | null>(null); + + // offset을 이미지 경계 내로 제한 + const clampOffset = useCallback( + (next: { x: number; y: number }, currentZoom: number) => { + if (!baseSize || !imageContainerRef.current) { + return next; + } + const container = imageContainerRef.current.getBoundingClientRect(); + const maxX = Math.max(0, (baseSize.width * currentZoom - container.width) / 2); + const maxY = Math.max(0, (baseSize.height * currentZoom - container.height) / 2); + return { + x: Math.min(maxX, Math.max(-maxX, next.x)), + y: Math.min(maxY, Math.max(-maxY, next.y)) + }; + }, + [baseSize, imageContainerRef] + ); + + // zoom 변경 시 offset 조정 + useEffect(() => { + setOffset((current) => clampOffset(current, zoom)); + }, [zoom, clampOffset]); + + // 드래그 중 포인터 이동 처리 + useEffect(() => { + if (!isDragging) { + return; + } + const handleMove = (event: PointerEvent) => { + if (!dragStateRef.current) { + return; + } + const dx = event.clientX - dragStateRef.current.startX; + const dy = event.clientY - dragStateRef.current.startY; + const next = { + x: dragStateRef.current.originX + dx, + y: dragStateRef.current.originY + dy + }; + setOffset(clampOffset(next, zoom)); + }; + const handleUp = () => { + setIsDragging(false); + dragStateRef.current = null; + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + return () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + }; + }, [clampOffset, zoom, isDragging]); + + // 휠 이벤트 핸들러 (줌 조정) + const handleWheel = useCallback((event: React.WheelEvent) => { + event.preventDefault(); + const delta = event.deltaY > 0 ? -0.1 : 0.1; + setZoom((current) => Math.min(3, Math.max(0.6, current + delta))); + }, []); + + // 이미지 로드 완료 시 base 크기 설정 + const handleImageLoad = useCallback(() => { + if (!imageRef.current) { + return; + } + requestAnimationFrame(() => { + if (!imageRef.current) { + return; + } + const rect = imageRef.current.getBoundingClientRect(); + setBaseSize({ width: rect.width, height: rect.height }); + }); + }, [imageRef]); + + // 포인터 다운 시 드래그 시작 + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + event.preventDefault(); + dragStateRef.current = { + startX: event.clientX, + startY: event.clientY, + originX: offset.x, + originY: offset.y + }; + setIsDragging(true); + }, + [offset] + ); + + // 줌/오프셋 초기화 + const reset = useCallback(() => { + setZoom(1); + setOffset({ x: 0, y: 0 }); + setBaseSize(null); + setIsDragging(false); + dragStateRef.current = null; + }, []); + + return { + // 상태 + zoom, + offset, + baseSize, + isDragging, + + // 핸들러 + handleWheel, + handleImageLoad, + handlePointerDown, + + // 유틸리티 + reset, + setZoom + }; +}; From b60f9cf78a47268c9e9080a7c78eade9bae0ad1d Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Fri, 9 Jan 2026 14:29:29 +0900 Subject: [PATCH 05/13] =?UTF-8?q?refactor:=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20UI=EB=A5=BC=20AccountLabel=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계정 정보 표시 패턴(아바타 + 표시명 + 핸들)을 재사용 가능한 컴포넌트로 분리했습니다. 변경 사항: - AccountLabel 컴포넌트 생성 (115줄) - 아바타 + 표시명 + 핸들 통합 표시 - 유연한 props로 다양한 사용 사례 지원 - 아바타 전용 모드 지원 - 크기, 스타일 커스터마이징 가능 - 인터랙션 핸들러 지원 (클릭, 키보드) - AccountManager 적용 - 계정 목록 항목에서 중복 코드 제거 - AccountSelector 적용 - 활성 계정 요약 표시 - 드롭다운 계정 목록 항목 - 2개 위치에서 중복 코드 제거 총 51줄의 중복 코드를 제거하고 재사용 가능한 컴포넌트로 통합했습니다. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/ui/components/AccountLabel.tsx | 115 ++++++++++++++++++++++++++ src/ui/components/AccountManager.tsx | 25 ++---- src/ui/components/AccountSelector.tsx | 49 ++++------- 3 files changed, 138 insertions(+), 51 deletions(-) create mode 100644 src/ui/components/AccountLabel.tsx diff --git a/src/ui/components/AccountLabel.tsx b/src/ui/components/AccountLabel.tsx new file mode 100644 index 0000000..6142f89 --- /dev/null +++ b/src/ui/components/AccountLabel.tsx @@ -0,0 +1,115 @@ +import React from "react"; + +export interface AccountLabelProps { + /** 아바타 이미지 URL */ + avatarUrl?: string | null; + /** 표시 이름 (우선순위 1) */ + displayName?: string | null; + /** 계정 이름 (우선순위 2, displayName이 없을 때) */ + name?: string | null; + /** 핸들 (@username 형식) */ + handle?: string | null; + /** 인스턴스 URL (우선순위 3, displayName과 name이 모두 없을 때) */ + instanceUrl?: string; + /** 계정 프로필 URL (링크 처리용) */ + accountUrl?: string | null; + /** 클릭 핸들러 */ + onClick?: () => void; + /** 키보드 이벤트 핸들러 */ + onKeyDown?: (event: React.KeyboardEvent) => void; + /** 아바타만 표시할지 여부 */ + avatarOnly?: boolean; + /** 아바타를 숨길지 여부 */ + hideAvatar?: boolean; + /** 커스텀 클래스 이름 */ + className?: string; + /** 아바타 크기 (기본값: 32px) */ + avatarSize?: number; + /** 아리아 레이블 */ + ariaLabel?: string; + /** 커스텀 이름 렌더링 (이모지 등 HTML 포함) */ + customNameNode?: React.ReactNode; +} + +/** + * 계정 정보를 표시하는 재사용 가능한 컴포넌트 + * 아바타 + 표시명 + 핸들 패턴을 통합 + */ +export const AccountLabel: React.FC = ({ + avatarUrl, + displayName, + name, + handle, + instanceUrl, + accountUrl, + onClick, + onKeyDown, + avatarOnly = false, + hideAvatar = false, + className = "", + avatarSize = 32, + ariaLabel, + customNameNode +}) => { + const effectiveDisplayName = displayName || name || instanceUrl || "알 수 없음"; + const isInteractive = !!(onClick || accountUrl); + + const avatarElement = !hideAvatar ? ( +
+
+
+
+
+
+
+ {displayProfile.avatarUrl ? ( + {`${displayName} + ) : ( +
+
+ {renderTextWithEmojis(displayName, "profile-name", false)} + {handleText ? {handleText} : null} +
+
+
+ {profileLoading ?

프로필을 불러오는 중...

: null} + {profileError ?

{profileError}

: null} + {bioContent + ? bioContent.type === "html" + ?
+ :
{bioContent.value}
+ : null} + {displayProfile.fields.length > 0 ? ( +
+ {displayProfile.fields.map((field, index) => ( +
+
{renderFieldLabel(field.label, index)}
+
{renderFieldValue(field.value, index)}
+
+ ))} +
+ ) : null} +
+
+

작성한 글

+ {itemsError ?

{itemsError}

: null} + {itemsLoading && items.length === 0 ?

게시글을 불러오는 중...

: null} + {!itemsLoading && items.length === 0 ?

표시할 글이 없습니다.

: null} + {items.length > 0 ? ( +
+ {items.map((item) => ( + onReply(target, account)} + onToggleFavourite={handleToggleFavourite} + onToggleReblog={handleToggleReblog} + onDelete={handleDeleteStatus} + onReact={handleReact} + onStatusClick={onStatusClick} + onProfileClick={(target) => onProfileClick(target, account)} + activeHandle={activeHandle} + activeAccountHandle={account?.handle ?? ""} + activeAccountUrl={account?.url ?? null} + account={account} + api={api} + showProfileImage={showProfileImage} + showCustomEmojis={showCustomEmojis} + showReactions={showReactions} + /> + ))} +
+ ) : null} + {itemsLoadingMore ?

더 불러오는 중...

: null} +
+
+
+ ); +}; diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index d163657..813bb8e 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Account, CustomEmoji, ReactionInput, Status } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; import { sanitizeHtml } from "../utils/htmlSanitizer"; +import { renderTextWithLinks } from "../utils/linkify"; import boostIconUrl from "../assets/boost-icon.svg"; import replyIconUrl from "../assets/reply-icon.svg"; import trashIconUrl from "../assets/trash-icon.svg"; @@ -17,6 +18,7 @@ export const TimelineItem = ({ onToggleReblog, onDelete, onReact, + onProfileClick, onStatusClick, account, api, @@ -35,6 +37,7 @@ export const TimelineItem = ({ onToggleReblog: (status: Status) => void; onDelete: (status: Status) => void; onReact?: (status: Status, reaction: ReactionInput) => void; + onProfileClick?: (status: Status) => void; onStatusClick?: (status: Status) => void; account: Account | null; api: MastodonApi; @@ -58,7 +61,7 @@ export const TimelineItem = ({ const menuRef = useRef(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - // useImageZoom 훅 사용 + // useImageZoom ???ъ슜 const { zoom: imageZoom, offset: imageOffset, @@ -152,13 +155,15 @@ export const TimelineItem = ({ } if (boostedBy) { const label = boostedBy.name || boostedHandle || boostedBy.handle; - return `${label} 님이 부스트함`; + return `${label}이 부스트함`; } if (displayStatus.reblogged) { return "내가 부스트함"; } return null; }, [boostedBy, boostedHandle, displayStatus.reblogged, activeHandle, notification]); + const canOpenProfile = Boolean(onProfileClick); + const profileLabel = `${displayStatus.accountName || displayStatus.accountHandle} 프로필 보기`; const notificationActorHandle = useMemo(() => { if (!notification) { return ""; @@ -185,8 +190,8 @@ export const TimelineItem = ({ return null; } const actorName = - notification.actor.name || notificationActorHandle || notification.actor.handle || "알 수 없음"; - return `${actorName} 님이 ${notification.label}`; + notification.actor.name || notificationActorHandle || notification.actor.handle || "?????놁쓬"; + return `${actorName} ?섏씠 ${notification.label}`; }, [notification, notificationActorHandle]); const timestamp = useMemo( () => new Date(displayStatus.createdAt).toLocaleString(), @@ -216,6 +221,13 @@ export const TimelineItem = ({ }, [activeAccountUrl, activeHandle, displayStatus.id, displayStatus.url]); const handleHeaderClick = useCallback( (event: React.MouseEvent) => { + if (onProfileClick) { + if (event.target instanceof Element && event.target.closest("a")) { + return; + } + onProfileClick(displayStatus); + return; + } if (!displayStatus.accountUrl) { return; } @@ -230,20 +242,24 @@ export const TimelineItem = ({ } window.open(displayStatus.accountUrl, "_blank", "noopener,noreferrer"); }, - [displayStatus.accountUrl] + [displayStatus, displayStatus.accountUrl, onProfileClick] ); const handleHeaderKeyDown = useCallback( (event: React.KeyboardEvent) => { - if (!displayStatus.accountUrl) { - return; - } if (event.key !== "Enter" && event.key !== " ") { return; } event.preventDefault(); + if (onProfileClick) { + onProfileClick(displayStatus); + return; + } + if (!displayStatus.accountUrl) { + return; + } window.open(displayStatus.accountUrl, "_blank", "noopener,noreferrer"); }, - [displayStatus.accountUrl] + [displayStatus, displayStatus.accountUrl, onProfileClick] ); useClickOutside(menuRef, menuOpen, () => setMenuOpen(false)); @@ -340,36 +356,6 @@ export const TimelineItem = ({ return tokens; }, []); - const renderTextWithLinks = useCallback((text: string, keyPrefix: string) => { - const regex = /(https?:\/\/[^\s)\]]+|www\.[^\s)\]]+|[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:\/[^\s)\]]*)?)(?=[^\w@]|$)/g; - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - let key = 0; - while ((match = regex.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); - } - const url = match[0]; - // Skip email addresses - if (url.includes('@')) { - parts.push(url); - } else { - const normalizedUrl = url.match(/^https?:\/\//) ? url : `https://${url}`; - parts.push( - - {url} - - ); - } - key += 1; - lastIndex = match.index + url.length; - } - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); - } - return parts; - }, []); const contentParts = useMemo(() => { // Check if content actually contains HTML tags before rendering as HTML @@ -431,7 +417,6 @@ export const TimelineItem = ({ displayStatus.customEmojis, displayStatus.htmlContent, displayStatus.hasRichContent, - renderTextWithLinks, showCustomEmojis, tokenizeWithEmojis ]); @@ -575,7 +560,7 @@ export const TimelineItem = ({ {mentionNames ? (
- {mentionNames}에게 보낸 답글 + {mentionNames}?먭쾶 蹂대궦 ?듦?
) : null}
@@ -585,14 +570,10 @@ export const TimelineItem = ({ className="status-avatar" onClick={handleHeaderClick} onKeyDown={handleHeaderKeyDown} - role={displayStatus.accountUrl ? "link" : undefined} - tabIndex={displayStatus.accountUrl ? 0 : undefined} - aria-label={ - displayStatus.accountUrl - ? `${displayStatus.accountName || displayStatus.accountHandle} 프로필 열기` - : undefined - } - data-interactive={displayStatus.accountUrl ? "true" : undefined} + role={canOpenProfile ? "button" : displayStatus.accountUrl ? "link" : undefined} + tabIndex={canOpenProfile || displayStatus.accountUrl ? 0 : undefined} + aria-label={canOpenProfile ? profileLabel : displayStatus.accountUrl ? profileLabel : undefined} + data-interactive={canOpenProfile || displayStatus.accountUrl ? "true" : undefined} > {displayStatus.accountAvatarUrl ? ( - {displayStatus.accountUrl ? ( + {displayStatus.accountUrl && !canOpenProfile ? ( {accountNameNode} @@ -628,7 +605,7 @@ export const TimelineItem = ({ )} - {displayStatus.accountUrl ? ( + {displayStatus.accountUrl && !canOpenProfile ? ( @{displayHandle} @@ -642,8 +619,7 @@ export const TimelineItem = ({ {attachments.length > 1 ? ( + {showUnfollowConfirm ? ( +
+
setShowUnfollowConfirm(false)} + /> +
+

정말 언팔로우할까요?

+
+ + +
+
+
+ ) : null} +
+
+ ) : null}
+ {followError ?

{followError}

: null} {profileLoading ?

프로필을 불러오는 중...

: null} {profileError ?

{profileError}

: null} {bioContent diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx index 53a719a..5126e51 100644 --- a/src/ui/components/StatusModal.tsx +++ b/src/ui/components/StatusModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import type { Account, Status, ThreadContext } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; import { TimelineItem } from "./TimelineItem"; @@ -9,11 +9,13 @@ export const StatusModal = ({ account, threadAccount, api, + zIndex, onClose, onReply, onToggleFavourite, onToggleReblog, onDelete, + onProfileClick, activeHandle, activeAccountHandle, activeAccountUrl, @@ -25,11 +27,13 @@ export const StatusModal = ({ account: Account | null; threadAccount: Account | null; api: MastodonApi; + zIndex?: number; onClose: () => void; onReply: (status: Status) => void; onToggleFavourite: (status: Status) => void; onToggleReblog: (status: Status) => void; onDelete?: (status: Status) => void; + onProfileClick?: (status: Status, account: Account | null) => void; activeHandle: string; activeAccountHandle: string; activeAccountUrl: string | null; @@ -39,6 +43,15 @@ export const StatusModal = ({ }) => { const displayStatus = status.reblog ?? status; const boostedBy = status.reblog ? status.boostedBy : null; + const handleProfileClick = useCallback( + (target: Status) => { + if (!onProfileClick) { + return; + } + onProfileClick(target, threadAccount ?? account ?? null); + }, + [account, onProfileClick, threadAccount] + ); // 스레드 컨텍스트 상태 const [threadContext, setThreadContext] = useState(null); @@ -75,6 +88,7 @@ export const StatusModal = ({ role="dialog" aria-modal="true" aria-label="글 보기" + style={zIndex ? { zIndex } : undefined} >
@@ -113,6 +127,7 @@ export const StatusModal = ({ onToggleFavourite={onToggleFavourite} onToggleReblog={onToggleReblog} onDelete={onDelete || (() => {})} + onProfileClick={handleProfileClick} activeHandle={activeHandle} activeAccountHandle={activeAccountHandle} activeAccountUrl={activeAccountUrl} @@ -142,6 +157,7 @@ export const StatusModal = ({ onToggleFavourite={onToggleFavourite} onToggleReblog={onToggleReblog} onDelete={onDelete || (() => {})} + onProfileClick={handleProfileClick} activeHandle={activeHandle} activeAccountHandle={activeAccountHandle} activeAccountUrl={activeAccountUrl} @@ -170,6 +186,7 @@ export const StatusModal = ({ onToggleFavourite={onToggleFavourite} onToggleReblog={onToggleReblog} onDelete={onDelete || (() => {})} + onProfileClick={handleProfileClick} activeHandle={activeHandle} activeAccountHandle={activeAccountHandle} activeAccountUrl={activeAccountUrl} diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 3cc2cac..4e18101 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1654,7 +1654,7 @@ button.ghost { .status-modal { position: fixed; inset: 0; - z-index: 50; + z-index: 60; display: flex; align-items: center; justify-content: center; @@ -1818,7 +1818,100 @@ button.ghost { padding: 16px 16px 0; color: var(--color-status-modal-title); width: 100%; - transform: translateY(48px); + top: 48px; + justify-content: space-between; + flex-wrap: wrap; +} + +.profile-hero-main { + display: flex; + align-items: flex-end; + gap: 12px; + min-width: 0; + flex: 1 1 auto; +} + +.profile-hero-actions { + display: flex; + align-items: flex-end; + margin-left: auto; +} + +.follow-action { + position: relative; +} + +.profile-follow-button { + border: 1px solid transparent; + justify-content: flex-start; +} + +.profile-follow-button.is-following, +.profile-follow-button.is-requested { + background: var(--color-secondary-button-bg); + color: var(--color-secondary-button-text); + border-color: var(--color-secondary-button-border); +} + +.follow-confirm { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 60; + display: flex; + justify-content: flex-end; +} + +.follow-confirm::before { + content: ""; + position: fixed; + inset: 0; + background: var(--color-overlay-backdrop); +} + +.follow-confirm-backdrop { + position: fixed; + inset: 0; + background: transparent; +} + +.follow-confirm-tooltip { + position: relative; + z-index: 1; + background: var(--color-status-modal-bg); + border: 1px solid var(--color-status-modal-border); + border-radius: 12px; + box-shadow: var(--shadow-status-modal); + padding: 12px 14px; + width: min(300px, 84vw); + display: flex; + flex-direction: column; + gap: 10px; +} + +.follow-confirm-tooltip p { + margin: 0; + font-size: 13px; + color: var(--color-text-primary); +} + +.follow-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.follow-confirm-actions button { + height: 32px; + padding: 6px 12px; + font-size: 13px; +} + +.follow-confirm-actions .delete-button { + width: auto; + height: auto; + padding: 6px 12px; + color: var(--color-delete-button-text); } .profile-avatar { From 2d12126ffdf6ae2a276c9a03a2fc5dd7e0b0d7fc Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Sat, 10 Jan 2026 22:10:39 +0900 Subject: [PATCH 13/13] Fix mention handle profile link --- src/ui/components/TimelineItem.tsx | 135 ++++++++++++++++++++++++++++- src/ui/utils/linkify.ts | 83 +++++++++++++++--- 2 files changed, 205 insertions(+), 13 deletions(-) diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index ff51dc8..12d5390 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Account, CustomEmoji, ReactionInput, Status } from "../../domain/types"; +import type { Account, CustomEmoji, Mention, ReactionInput, Status } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; import { sanitizeHtml } from "../utils/htmlSanitizer"; import { renderTextWithLinks } from "../utils/linkify"; @@ -11,6 +11,19 @@ import { useClickOutside } from "../hooks/useClickOutside"; import { useImageZoom } from "../hooks/useImageZoom"; import { AccountLabel } from "./AccountLabel"; +const normalizeMentionHandle = (handle: string): string => + handle.replace(/^@/, "").trim().toLowerCase(); + +const normalizeMentionUrl = (url: string): string | null => { + try { + const parsed = new URL(url); + const pathname = parsed.pathname.replace(/\/$/, ""); + return `${parsed.protocol}//${parsed.hostname}${pathname}`; + } catch { + return null; + } +}; + export const TimelineItem = ({ status, onReply, @@ -331,6 +344,113 @@ export const TimelineItem = ({ const buildEmojiMap = useCallback((emojis: CustomEmoji[]) => { return new Map(emojis.map((emoji) => [emoji.shortcode, emoji.url])); }, []); + const mentionMap = useMemo(() => { + const map = new Map(); + displayStatus.mentions.forEach((mention) => { + const key = normalizeMentionHandle(mention.handle); + if (key) { + map.set(key, mention); + } + }); + return map; + }, [displayStatus.mentions]); + const mentionUrlMap = useMemo(() => { + const map = new Map(); + displayStatus.mentions.forEach((mention) => { + if (!mention.url) { + return; + } + const normalized = normalizeMentionUrl(mention.url); + if (normalized) { + map.set(normalized, mention); + } + }); + return map; + }, [displayStatus.mentions]); + const buildMentionStatus = useCallback( + (mention: Mention): Status => ({ + id: mention.id ? `mention-${mention.id}` : `mention-${displayStatus.id}-${mention.handle}`, + createdAt: displayStatus.createdAt, + accountId: mention.id || null, + accountName: mention.displayName || mention.handle, + accountHandle: mention.handle, + accountUrl: mention.url, + accountAvatarUrl: null, + content: "", + htmlContent: "", + hasRichContent: false, + url: mention.url, + visibility: "public", + spoilerText: "", + sensitive: false, + card: null, + repliesCount: 0, + reblogsCount: 0, + favouritesCount: 0, + reactions: [], + reblogged: false, + favourited: false, + inReplyToId: null, + mentions: [], + mediaAttachments: [], + reblog: null, + boostedBy: null, + notification: null, + myReaction: null, + customEmojis: [], + accountEmojis: [] + }), + [displayStatus.createdAt, displayStatus.id] + ); + const handleMentionClick = useCallback( + (mention: Mention) => { + if (!onProfileClick || !mention.id) { + return; + } + onProfileClick(buildMentionStatus(mention)); + }, + [buildMentionStatus, onProfileClick] + ); + const resolveMention = useCallback( + (handle: string) => { + const mention = mentionMap.get(handle.toLowerCase()) ?? null; + return mention?.id ? mention : null; + }, + [mentionMap] + ); + const handleRichContentClick = useCallback( + (event: React.MouseEvent) => { + if (!onProfileClick || !(event.target instanceof Element)) { + return; + } + const anchor = event.target.closest("a"); + if (!anchor) { + return; + } + const href = anchor.getAttribute("href"); + if (!href) { + return; + } + const hasMentionClass = anchor.classList.contains("mention"); + const looksLikeMention = hasMentionClass || href.includes("/@"); + if (!looksLikeMention) { + return; + } + const normalizedHref = normalizeMentionUrl(href); + let mention = normalizedHref ? mentionUrlMap.get(normalizedHref) ?? null : null; + if (!mention) { + const text = anchor.textContent ?? ""; + const normalizedHandle = normalizeMentionHandle(text); + mention = normalizedHandle ? mentionMap.get(normalizedHandle) ?? null : null; + } + if (!mention?.id) { + return; + } + event.preventDefault(); + onProfileClick(buildMentionStatus(mention)); + }, + [buildMentionStatus, mentionMap, mentionUrlMap, onProfileClick] + ); const tokenizeWithEmojis = useCallback((text: string, emojiMap: Map) => { const regex = /:([a-zA-Z0-9_]+):/g; @@ -383,6 +503,7 @@ export const TimelineItem = ({
); } @@ -390,14 +511,22 @@ export const TimelineItem = ({ // Fallback to plain text with link detection const text = displayStatus.content; if (!showCustomEmojis || displayStatus.customEmojis.length === 0) { - return renderTextWithLinks(text, "content"); + return renderTextWithLinks(text, "content", { + mentionResolver: resolveMention, + onMentionClick: handleMentionClick + }); } const emojiMap = buildEmojiMap(displayStatus.customEmojis); const tokens = tokenizeWithEmojis(text, emojiMap); const parts: React.ReactNode[] = []; tokens.forEach((token, index) => { if (token.type === "text") { - parts.push(...renderTextWithLinks(token.value, `content-${index}`)); + parts.push( + ...renderTextWithLinks(token.value, `content-${index}`, { + mentionResolver: resolveMention, + onMentionClick: handleMentionClick + }) + ); } else { parts.push( MentionLink | null; + onMentionClick?: (mention: MentionLink) => void; +}; + +const mentionPattern = + /@[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?=[^\w@]|$)/g; const urlPattern = /(https?:\/\/[^\s)\]]+|www\.[^\s)\]]+|[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:\/[^\s)\]]*)?)(?=[^\w@]|$)/g; +const linkPattern = new RegExp(`${mentionPattern.source}|${urlPattern.source}`, "g"); const normalizeUrl = (url: string): string => (url.match(/^https?:\/\//) ? url : `https://${url}`); +const normalizeMentionHandle = (handle: string): string => handle.replace(/^@/, "").trim().toLowerCase(); +const buildMentionUrl = (handle: string): string | null => { + const normalized = normalizeMentionHandle(handle); + if (!normalized.includes("@")) { + return null; + } + const [username, ...rest] = normalized.split("@"); + const host = rest.join("@"); + if (!username || !host) { + return null; + } + return `https://${host}/@${username}`; +}; export const isPlainUrl = (value: string): boolean => { const trimmed = value.trim(); @@ -15,30 +43,64 @@ export const isPlainUrl = (value: string): boolean => { ); }; -export const renderTextWithLinks = (text: string, keyPrefix: string): React.ReactNode[] => { +export const renderTextWithLinks = ( + text: string, + keyPrefix: string, + options?: LinkifyOptions +): React.ReactNode[] => { + linkPattern.lastIndex = 0; const parts: React.ReactNode[] = []; let lastIndex = 0; let match: RegExpExecArray | null; let key = 0; - while ((match = urlPattern.exec(text)) !== null) { + while ((match = linkPattern.exec(text)) !== null) { if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)); } - const url = match[0]; - if (url.includes("@")) { - parts.push(url); + const matched = match[0]; + if (matched.startsWith("@") && matched.includes("@")) { + const normalizedHandle = normalizeMentionHandle(matched); + const mention = options?.mentionResolver?.(normalizedHandle) ?? null; + const mentionUrl = mention?.url ?? buildMentionUrl(matched); + if (mention && options?.onMentionClick) { + parts.push( + React.createElement( + "button", + { + key: `${keyPrefix}-mention-${key}`, + type: "button", + className: "text-link", + onClick: () => options.onMentionClick?.(mention), + "aria-label": `${mention.displayName ?? mention.handle} 프로필 보기` + }, + matched + ) + ); + } else if (mentionUrl) { + parts.push( + React.createElement( + "a", + { key: `${keyPrefix}-mention-${key}`, href: mentionUrl, target: "_blank", rel: "noreferrer" }, + matched + ) + ); + } else { + parts.push(matched); + } + } else if (matched.includes("@")) { + parts.push(matched); } else { - const normalizedUrl = normalizeUrl(url); + const normalizedUrl = normalizeUrl(matched); parts.push( React.createElement( "a", { key: `${keyPrefix}-link-${key}`, href: normalizedUrl, target: "_blank", rel: "noreferrer" }, - url + matched ) ); } key += 1; - lastIndex = match.index + url.length; + lastIndex = match.index + matched.length; } if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); @@ -48,7 +110,8 @@ export const renderTextWithLinks = (text: string, keyPrefix: string): React.Reac export const renderTextWithLinksAndLineBreaks = ( text: string, - keyPrefix: string + keyPrefix: string, + options?: LinkifyOptions ): React.ReactNode[] => { const lines = text.split(/\r?\n/); const nodes: React.ReactNode[] = []; @@ -56,7 +119,7 @@ export const renderTextWithLinksAndLineBreaks = ( if (index > 0) { nodes.push(React.createElement("br", { key: `${keyPrefix}-br-${index}` })); } - nodes.push(...renderTextWithLinks(line, `${keyPrefix}-line-${index}`)); + nodes.push(...renderTextWithLinks(line, `${keyPrefix}-line-${index}`, options)); }); return nodes; };