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은 코드 블록 없이 클릭 가능한 일반 텍스트로 제공한다. diff --git a/src/App.tsx b/src/App.tsx index fd1a32f..368381e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,9 +3,11 @@ import type { Account, Reaction, ReactionInput, Status, TimelineType } from "./d import { AccountAdd } from "./ui/components/AccountAdd"; import { AccountSelector } from "./ui/components/AccountSelector"; import { ComposeBox } from "./ui/components/ComposeBox"; +import { ProfileModal } from "./ui/components/ProfileModal"; 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"; @@ -16,6 +18,7 @@ import licenseText from "../LICENSE?raw"; type Route = "home" | "terms" | "license" | "oss"; type TimelineSectionConfig = { id: string; accountId: string | null; timelineType: TimelineType }; +type ProfileTarget = { status: Status; account: Account | null; zIndex: number }; const SECTION_STORAGE_KEY = "textodon.sections"; const COMPOSE_ACCOUNT_KEY = "textodon.compose.accountId"; @@ -231,6 +234,7 @@ const TimelineSection = ({ onStatusClick, onCloseStatusModal, onReact, + onProfileClick, onError, onMoveSection, canMoveLeft, @@ -255,6 +259,7 @@ const TimelineSection = ({ onReply: (status: Status, account: Account | null) => void; onStatusClick: (status: Status, columnAccount: Account | null) => void; onReact: (account: Account | null, status: Status, reaction: ReactionInput) => void; + onProfileClick: (status: Status, account: Account | null) => void; onError: (message: string | null) => void; columnAccount: Account | null; onMoveSection: (sectionId: string, direction: "left" | "right") => void; @@ -349,80 +354,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 +478,7 @@ const TimelineSection = ({ variant="inline" />
-
+
-
+
) : null} + {profileTargets.map((target, index) => ( + handleCloseProfileModal(index)} + onReply={handleReply} + onStatusClick={(status) => handleStatusClick(status, target.account)} + onProfileClick={handleProfileOpen} + showProfileImage={showProfileImages} + showCustomEmojis={showCustomEmojis} + showReactions={showMisskeyReactions} + /> + ))} + {selectedStatus ? ( { if (composeAccount) { handleReply(status, composeAccount); diff --git a/src/domain/types.ts b/src/domain/types.ts index 326d461..885b0da 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -74,6 +74,7 @@ export type LinkCard = { export type Status = { id: string; createdAt: string; + accountId: string | null; accountName: string; accountHandle: string; accountUrl: string | null; @@ -103,6 +104,28 @@ export type Status = { accountEmojis: CustomEmoji[]; }; +export type ProfileField = { + label: string; + value: string; +}; + +export type UserProfile = { + id: string; + name: string; + handle: string; + url: string | null; + avatarUrl: string | null; + headerUrl: string | null; + locked: boolean; + bio: string; + fields: ProfileField[]; +}; + +export type AccountRelationship = { + following: boolean; + requested: boolean; +}; + export type ThreadContext = { ancestors: Status[]; descendants: Status[]; diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index b620753..2ebd6a7 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -1,6 +1,15 @@ -import type { Account, CustomEmoji, Status, ThreadContext, TimelineType, InstanceInfo } from "../domain/types"; +import type { + Account, + AccountRelationship, + CustomEmoji, + Status, + ThreadContext, + TimelineType, + InstanceInfo, + UserProfile +} from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; -import { mapNotificationToStatus, mapStatus } from "./mastodonMapper"; +import { mapAccountProfile, mapAccountRelationship, mapNotificationToStatus, mapStatus } from "./mastodonMapper"; const buildHeaders = (account: Account): HeadersInit => ({ Authorization: `Bearer ${account.accessToken}`, @@ -172,6 +181,80 @@ export class MastodonHttpClient implements MastodonApi { }; } + async fetchAccountProfile(account: Account, accountId: string): Promise { + const response = await fetch(`${account.instanceUrl}/api/v1/accounts/${accountId}`, { + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("?„로???•ë³´ë¥?불러?¤ì? 못했?µë‹ˆ??"); + } + const data = (await response.json()) as unknown; + return mapAccountProfile(data); + } + + async fetchAccountRelationship(account: Account, accountId: string): Promise { + const url = new URL(`${account.instanceUrl}/api/v1/accounts/relationships`); + url.searchParams.append("id[]", accountId); + const response = await fetch(url.toString(), { + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("관계 정보를 불러오지 못했습니다."); + } + const data = (await response.json()) as unknown[]; + const relationship = data[0]; + return mapAccountRelationship(relationship); + } + + async followAccount(account: Account, accountId: string): Promise { + const response = await fetch(`${account.instanceUrl}/api/v1/accounts/${accountId}/follow`, { + method: "POST", + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("팔로우에 실패했습니다."); + } + const data = (await response.json()) as unknown; + return mapAccountRelationship(data); + } + + async unfollowAccount(account: Account, accountId: string): Promise { + const response = await fetch(`${account.instanceUrl}/api/v1/accounts/${accountId}/unfollow`, { + method: "POST", + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("언팔로우에 실패했습니다."); + } + const data = (await response.json()) as unknown; + return mapAccountRelationship(data); + } + + async cancelFollowRequest(account: Account, accountId: string): Promise { + return this.unfollowAccount(account, accountId); + } + + async fetchAccountStatuses( + account: Account, + accountId: string, + limit: number, + maxId?: string + ): Promise { + const url = new URL(`${account.instanceUrl}/api/v1/accounts/${accountId}/statuses`); + url.searchParams.set("limit", String(limit)); + if (maxId) { + url.searchParams.set("max_id", maxId); + } + const response = await fetch(url.toString(), { + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("?„로???글?„ë¥?불러?¤ì? 못했?µë‹ˆ??"); + } + const data = (await response.json()) as unknown[]; + return data.map(mapStatus); + } + async uploadMedia(account: Account, file: File): Promise { const formData = new FormData(); formData.append("file", file); diff --git a/src/infra/MisskeyHttpClient.ts b/src/infra/MisskeyHttpClient.ts index ad1e461..bb6f0a3 100644 --- a/src/infra/MisskeyHttpClient.ts +++ b/src/infra/MisskeyHttpClient.ts @@ -1,6 +1,20 @@ -import type { Account, CustomEmoji, Status, ThreadContext, TimelineType, InstanceInfo } from "../domain/types"; +import type { + Account, + AccountRelationship, + CustomEmoji, + Status, + ThreadContext, + TimelineType, + InstanceInfo, + UserProfile +} from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; -import { mapMisskeyNotification, mapMisskeyStatusWithInstance } from "./misskeyMapper"; +import { + mapMisskeyNotification, + mapMisskeyRelationship, + mapMisskeyStatusWithInstance, + mapMisskeyUserProfile +} from "./misskeyMapper"; const normalizeInstanceUrl = (instanceUrl: string): string => instanceUrl.replace(/\/$/, ""); @@ -210,6 +224,77 @@ export class MisskeyHttpClient implements MastodonApi { }; } + async fetchAccountProfile(account: Account, accountId: string): Promise { + const response = await fetch(`${normalizeInstanceUrl(account.instanceUrl)}/api/users/show`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(buildBody(account, { userId: accountId })) + }); + if (!response.ok) { + throw new Error("?„로???•ë³´ë¥?불러?¤ì? 못했?µë‹ˆ??"); + } + const data = (await response.json()) as unknown; + return mapMisskeyUserProfile(data, account.instanceUrl); + } + + async fetchAccountRelationship(account: Account, accountId: string): Promise { + const response = await fetch(`${normalizeInstanceUrl(account.instanceUrl)}/api/users/show`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(buildBody(account, { userId: accountId })) + }); + if (!response.ok) { + throw new Error("관계 정보를 불러오지 못했습니다."); + } + const data = (await response.json()) as unknown; + return mapMisskeyRelationship(data); + } + + async followAccount(account: Account, accountId: string): Promise { + await this.postSimple(account, "/api/following/create", { userId: accountId }); + return this.fetchAccountRelationship(account, accountId); + } + + async unfollowAccount(account: Account, accountId: string): Promise { + await this.postSimple(account, "/api/following/delete", { userId: accountId }); + return this.fetchAccountRelationship(account, accountId); + } + + async cancelFollowRequest(account: Account, accountId: string): Promise { + await this.postSimple(account, "/api/following/requests/cancel", { userId: accountId }); + return this.fetchAccountRelationship(account, accountId); + } + + async fetchAccountStatuses( + account: Account, + accountId: string, + limit: number, + maxId?: string + ): Promise { + const response = await fetch(`${normalizeInstanceUrl(account.instanceUrl)}/api/users/notes`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify( + buildBody(account, { + userId: accountId, + limit, + untilId: maxId + }) + ) + }); + if (!response.ok) { + throw new Error("?„로???글?„ë¥?불러?¤ì? 못했?µë‹ˆ??"); + } + const data = (await response.json()) as unknown[]; + return data.map((item) => mapMisskeyStatusWithInstance(item, account.instanceUrl)); + } + async uploadMedia(account: Account, file: File): Promise { const formData = new FormData(); formData.append("i", account.accessToken); diff --git a/src/infra/UnifiedApiClient.ts b/src/infra/UnifiedApiClient.ts index c8c68d5..010bb9d 100644 --- a/src/infra/UnifiedApiClient.ts +++ b/src/infra/UnifiedApiClient.ts @@ -33,6 +33,30 @@ export class UnifiedApiClient implements MastodonApi { return client.fetchInstanceInfo(account); } + fetchAccountProfile(account: Account, accountId: string) { + return this.getClient(account).fetchAccountProfile(account, accountId); + } + + fetchAccountRelationship(account: Account, accountId: string) { + return this.getClient(account).fetchAccountRelationship(account, accountId); + } + + followAccount(account: Account, accountId: string) { + return this.getClient(account).followAccount(account, accountId); + } + + unfollowAccount(account: Account, accountId: string) { + return this.getClient(account).unfollowAccount(account, accountId); + } + + cancelFollowRequest(account: Account, accountId: string) { + return this.getClient(account).cancelFollowRequest(account, accountId); + } + + fetchAccountStatuses(account: Account, accountId: string, limit: number, maxId?: string) { + return this.getClient(account).fetchAccountStatuses(account, accountId, limit, maxId); + } + uploadMedia(account: Account, file: File) { return this.getClient(account).uploadMedia(account, file); } diff --git a/src/infra/mastodonMapper.ts b/src/infra/mastodonMapper.ts index 586684c..007461e 100644 --- a/src/infra/mastodonMapper.ts +++ b/src/infra/mastodonMapper.ts @@ -1,4 +1,4 @@ -import type { MediaAttachment, Reaction, Status } from "../domain/types"; +import type { AccountRelationship, MediaAttachment, ProfileField, Reaction, Status, UserProfile } from "../domain/types"; const htmlToText = (html: string): string => { // Preserve links as "text (url)" format before DOM parsing @@ -84,6 +84,62 @@ const mapCustomEmojis = (emojis: unknown): { shortcode: string; url: string }[] .filter((item): item is { shortcode: string; url: string } => item !== null); }; +const mapProfileFields = (fields: unknown): ProfileField[] => { + if (!Array.isArray(fields)) { + return []; + } + return fields + .map((item) => { + if (!item || typeof item !== "object") { + return null; + } + const typed = item as Record; + const label = typeof typed.name === "string" ? typed.name.trim() : ""; + const value = typeof typed.value === "string" ? typed.value.trim() : ""; + if (!label || !value) { + return null; + } + return { label, value }; + }) + .filter((item): item is ProfileField => item !== null); +}; + +export const mapAccountProfile = (raw: unknown): UserProfile => { + const value = raw as Record; + const id = String(value.id ?? ""); + const name = String(value.display_name ?? value.username ?? ""); + const handle = String(value.acct ?? value.username ?? ""); + const url = typeof value.url === "string" ? value.url : null; + const avatarUrl = typeof value.avatar === "string" ? value.avatar : null; + const headerUrl = + typeof value.header === "string" + ? value.header + : typeof value.header_static === "string" + ? value.header_static + : null; + const bio = typeof value.note === "string" ? value.note : ""; + const locked = Boolean(value.locked ?? false); + return { + id, + name, + handle, + url, + avatarUrl, + headerUrl, + locked, + bio, + fields: mapProfileFields(value.fields) + }; +}; + +export const mapAccountRelationship = (raw: unknown): AccountRelationship => { + const value = raw as Record; + return { + following: Boolean(value.following ?? false), + requested: Boolean(value.requested ?? false) + }; +}; + const getHostFromUrl = (url: string | null): string | null => { if (!url) { return null; @@ -149,6 +205,7 @@ export const mapStatus = (raw: unknown): Status => { const account = (value.account ?? {}) as Record; const reblogValue = value.reblog as Record | null | undefined; const reblog = reblogValue ? mapStatus(reblogValue) : null; + const accountId = typeof account.id === "string" ? account.id : null; const accountName = String(account.display_name ?? account.username ?? ""); const accountHandle = String(account.acct ?? ""); const accountUrl = typeof account.url === "string" ? account.url : null; @@ -168,6 +225,7 @@ export const mapStatus = (raw: unknown): Status => { return { id: String(value.id ?? ""), createdAt: String(value.created_at ?? ""), + accountId, accountName, accountHandle, accountUrl, @@ -249,6 +307,7 @@ export const mapNotificationToStatus = (raw: unknown): Status | null => { return null; } const account = (value.account ?? {}) as Record; + const accountId = typeof account.id === "string" ? account.id : null; const accountName = String(account.display_name ?? account.username ?? ""); const accountHandle = String(account.acct ?? account.username ?? ""); const accountUrl = typeof account.url === "string" ? account.url : null; @@ -259,6 +318,7 @@ export const mapNotificationToStatus = (raw: unknown): Status | null => { return { id: notificationId, createdAt: createdAt || status?.createdAt || "", + accountId, accountName, accountHandle, accountUrl, diff --git a/src/infra/misskeyMapper.ts b/src/infra/misskeyMapper.ts index 901f0bc..59fb7a9 100644 --- a/src/infra/misskeyMapper.ts +++ b/src/infra/misskeyMapper.ts @@ -1,4 +1,14 @@ -import type { CustomEmoji, MediaAttachment, Mention, Reaction, Status, Visibility } from "../domain/types"; +import type { + AccountRelationship, + CustomEmoji, + MediaAttachment, + Mention, + ProfileField, + Reaction, + Status, + UserProfile, + Visibility +} from "../domain/types"; const mapVisibility = (visibility: string): Visibility => { switch (visibility) { @@ -294,11 +304,68 @@ const buildAccountUrl = ( return `${base.replace(/\/$/, "")}/@${username}`; }; +const mapProfileFields = (fields: unknown): ProfileField[] => { + if (!Array.isArray(fields)) { + return []; + } + return fields + .map((item) => { + if (!item || typeof item !== "object") { + return null; + } + const typed = item as Record; + const label = typeof typed.name === "string" ? typed.name.trim() : ""; + const value = typeof typed.value === "string" ? typed.value.trim() : ""; + if (!label || !value) { + return null; + } + return { label, value }; + }) + .filter((item): item is ProfileField => item !== null); +}; + +export const mapMisskeyUserProfile = ( + raw: unknown, + instanceUrl?: string +): UserProfile => { + const value = raw as Record; + const id = String(value.id ?? ""); + const username = String(value.username ?? ""); + const host = typeof value.host === "string" ? value.host : ""; + const handle = host ? `${username}@${host}` : username; + const name = String(value.name ?? username ?? ""); + const url = buildAccountUrl(value, instanceUrl); + const avatarUrl = typeof value.avatarUrl === "string" ? value.avatarUrl : null; + const headerUrl = typeof value.bannerUrl === "string" ? value.bannerUrl : null; + const bio = typeof value.description === "string" ? value.description : ""; + const locked = Boolean(value.isLocked ?? value.isPrivate ?? false); + return { + id, + name, + handle, + url, + avatarUrl, + headerUrl, + locked, + bio, + fields: mapProfileFields(value.fields) + }; +}; + +export const mapMisskeyRelationship = (raw: unknown): AccountRelationship => { + const value = raw as Record; + return { + following: Boolean(value.isFollowing ?? false), + requested: Boolean(value.hasPendingFollowRequestFromYou ?? value.hasPendingFollowRequest ?? false) + }; +}; + export const mapMisskeyStatusWithInstance = (raw: unknown, instanceUrl?: string): Status => { const value = raw as Record; const user = (value.user ?? {}) as Record; const renoteValue = value.renote as Record | null | undefined; const renote = renoteValue ? mapMisskeyStatusWithInstance(renoteValue, instanceUrl) : null; + const accountId = typeof user.id === "string" ? user.id : null; const accountName = String(user.name ?? user.username ?? ""); const accountHandle = String(user.username ?? ""); const accountUrl = buildAccountUrl(user, instanceUrl); @@ -337,6 +404,7 @@ export const mapMisskeyStatusWithInstance = (raw: unknown, instanceUrl?: string) return { id: String(value.id ?? ""), createdAt: String(value.createdAt ?? ""), + accountId, accountName, accountHandle, accountUrl, @@ -662,6 +730,7 @@ export const mapMisskeyNotification = (raw: unknown, instanceUrl?: string): Stat return null; } const user = (value.user ?? {}) as Record; + const accountId = typeof user.id === "string" ? user.id : null; const accountName = String(user.name ?? user.username ?? ""); const accountHandle = String(user.username ?? ""); const accountUrl = buildAccountUrl(user, instanceUrl); @@ -698,6 +767,7 @@ export const mapMisskeyNotification = (raw: unknown, instanceUrl?: string): Stat return { id: notificationId, createdAt: createdAt || noteStatus?.createdAt || "", + accountId, accountName: normalizedAccountName, accountHandle: normalizedAccountHandle, accountUrl: normalizedAccountUrl, diff --git a/src/services/MastodonApi.ts b/src/services/MastodonApi.ts index 653f3b5..2e7d829 100644 --- a/src/services/MastodonApi.ts +++ b/src/services/MastodonApi.ts @@ -1,4 +1,4 @@ -import type { Account, Status, TimelineType, Visibility, InstanceInfo } from "../domain/types"; +import type { Account, AccountRelationship, Status, TimelineType, Visibility, InstanceInfo, UserProfile } from "../domain/types"; import type { CustomEmoji } from "../domain/types"; export type CreateStatusInput = { @@ -24,4 +24,10 @@ export interface MastodonApi { reblog(account: Account, statusId: string): Promise; unreblog(account: Account, statusId: string): Promise; fetchInstanceInfo(account: Account): Promise; + fetchAccountProfile(account: Account, accountId: string): Promise; + fetchAccountRelationship(account: Account, accountId: string): Promise; + followAccount(account: Account, accountId: string): Promise; + unfollowAccount(account: Account, accountId: string): Promise; + cancelFollowRequest(account: Account, accountId: string): Promise; + fetchAccountStatuses(account: Account, accountId: string, limit: number, maxId?: string): Promise; } diff --git a/src/ui/components/AccountLabel.tsx b/src/ui/components/AccountLabel.tsx new file mode 100644 index 0000000..26bf402 --- /dev/null +++ b/src/ui/components/AccountLabel.tsx @@ -0,0 +1,135 @@ +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; + /** 아바타 컨테이너 클래스 이름 (기본값: "account-avatar") */ + avatarClassName?: string; + /** 아바타 폴백 클래스 이름 (기본값: "account-avatar-fallback") */ + avatarFallbackClassName?: string; + /** 텍스트 컨테이너 클래스 이름 (기본값: "account-text") */ + textContainerClassName?: string; + /** 핸들 클래스 이름 (기본값: "account-handle") */ + handleClassName?: string; + /** 아바타 크기 (기본값: 32px) */ + avatarSize?: number; + /** 아리아 레이블 */ + ariaLabel?: string; + /** 커스텀 이름 렌더링 (이모지 등 HTML 포함) */ + customNameNode?: React.ReactNode; + /** 텍스트를 별도 div로 렌더링 (기본값: false, span 사용) */ + textAsDiv?: boolean; + /** 이름을 굵게 표시할지 여부 (기본값: false) */ + boldName?: boolean; +} + +/** + * 계정 정보를 표시하는 재사용 가능한 컴포넌트 + * 아바타 + 표시명 + 핸들 패턴을 통합 + */ +export const AccountLabel: React.FC = ({ + avatarUrl, + displayName, + name, + handle, + instanceUrl, + accountUrl, + onClick, + onKeyDown, + avatarOnly = false, + hideAvatar = false, + className = "", + avatarClassName = "account-avatar", + avatarFallbackClassName = "account-avatar-fallback", + textContainerClassName = "account-text", + handleClassName = "account-handle", + avatarSize = 32, + ariaLabel, + customNameNode, + textAsDiv = false, + boldName = false +}) => { + const effectiveDisplayName = displayName || name || instanceUrl || "알 수 없음"; + const isInteractive = !!(onClick || accountUrl); + + const avatarElement = !hideAvatar ? ( +
@@ -654,7 +473,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 +481,7 @@ export const ComposeBox = ({
diff --git a/src/ui/components/ProfileModal.tsx b/src/ui/components/ProfileModal.tsx new file mode 100644 index 0000000..0c2bc7f --- /dev/null +++ b/src/ui/components/ProfileModal.tsx @@ -0,0 +1,713 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + Account, + AccountRelationship, + CustomEmoji, + ReactionInput, + Status, + UserProfile +} from "../../domain/types"; +import type { MastodonApi } from "../../services/MastodonApi"; +import { sanitizeHtml } from "../utils/htmlSanitizer"; +import { formatHandle } from "../utils/account"; +import { isPlainUrl, renderTextWithLinks } from "../utils/linkify"; +import { useClickOutside } from "../hooks/useClickOutside"; +import { TimelineItem } from "./TimelineItem"; + +const PAGE_SIZE = 20; + +const buildFallbackProfile = (status: Status): UserProfile => ({ + id: status.accountId ?? "", + name: status.accountName || status.accountHandle, + handle: status.accountHandle, + url: status.accountUrl, + avatarUrl: status.accountAvatarUrl, + headerUrl: null, + locked: false, + bio: "", + fields: [] +}); + +const hasHtmlTags = (value: string): boolean => /<[^>]+>/.test(value); + +const buildEmojiMap = (emojis: CustomEmoji[]): Map => + new Map(emojis.map((emoji) => [emoji.shortcode, emoji.url])); + +const tokenizeWithEmojis = ( + text: string, + emojiMap: Map +): Array<{ type: "text"; value: string } | { type: "emoji"; name: string; url: string }> => { + const regex = /:([a-zA-Z0-9_]+):/g; + const tokens: Array<{ type: "text"; value: string } | { type: "emoji"; name: string; url: string }> = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + const shortcode = match[1]; + const url = emojiMap.get(shortcode); + if (match.index > lastIndex) { + tokens.push({ type: "text", value: text.slice(lastIndex, match.index) }); + } + if (url) { + tokens.push({ type: "emoji", name: shortcode, url }); + } else { + tokens.push({ type: "text", value: match[0] }); + } + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) { + tokens.push({ type: "text", value: text.slice(lastIndex) }); + } + return tokens; +}; + +export const ProfileModal = ({ + status, + account, + api, + zIndex, + isTopmost, + onClose, + onReply, + onStatusClick, + onProfileClick, + showProfileImage, + showCustomEmojis, + showReactions +}: { + status: Status; + account: Account | null; + api: MastodonApi; + zIndex?: number; + isTopmost: boolean; + onClose: () => void; + onReply: (status: Status, account: Account | null) => void; + onStatusClick: (status: Status) => void; + onProfileClick: (status: Status, account: Account | null) => void; + showProfileImage: boolean; + showCustomEmojis: boolean; + showReactions: boolean; +}) => { + const [profile, setProfile] = useState(null); + const [profileError, setProfileError] = useState(null); + const [profileLoading, setProfileLoading] = useState(false); + const [relationship, setRelationship] = useState(null); + const [followLoading, setFollowLoading] = useState(false); + const [followError, setFollowError] = useState(null); + const [showUnfollowConfirm, setShowUnfollowConfirm] = useState(false); + const [items, setItems] = useState([]); + const [itemsError, setItemsError] = useState(null); + const [itemsLoading, setItemsLoading] = useState(false); + const [itemsLoadingMore, setItemsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const scrollRef = useRef(null); + const targetAccountId = status.accountId; + const emojiMap = useMemo( + () => (showCustomEmojis ? buildEmojiMap(status.accountEmojis) : new Map()), + [showCustomEmojis, status.accountEmojis] + ); + + useClickOutside(scrollRef, isTopmost, onClose); + + useEffect(() => { + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previous; + }; + }, []); + + useEffect(() => { + if (!account || !targetAccountId) { + setProfile(null); + setProfileError("프로필 정보를 불러올 수 없습니다."); + setProfileLoading(false); + return; + } + let cancelled = false; + setProfile(null); + setProfileLoading(true); + setProfileError(null); + api + .fetchAccountProfile(account, targetAccountId) + .then((data) => { + if (cancelled) return; + setProfile(data); + }) + .catch((error) => { + if (cancelled) return; + setProfileError(error instanceof Error ? error.message : "프로필 정보를 불러오지 못했습니다."); + }) + .finally(() => { + if (cancelled) return; + setProfileLoading(false); + }); + return () => { + cancelled = true; + }; + }, [account, api, targetAccountId]); + + useEffect(() => { + if (!account || !targetAccountId || account.id === targetAccountId) { + setRelationship(null); + setFollowError(null); + setFollowLoading(false); + setShowUnfollowConfirm(false); + return; + } + let cancelled = false; + setFollowError(null); + api + .fetchAccountRelationship(account, targetAccountId) + .then((data) => { + if (cancelled) return; + setRelationship(data); + }) + .catch((error) => { + if (cancelled) return; + setRelationship(null); + setFollowError(error instanceof Error ? error.message : "관계 정보를 불러오지 못했습니다."); + }); + return () => { + cancelled = true; + }; + }, [account, api, targetAccountId]); + + useEffect(() => { + if (!account || !targetAccountId) { + setItems([]); + setHasMore(false); + setItemsLoading(false); + setItemsLoadingMore(false); + return; + } + let cancelled = false; + setItemsLoading(true); + setItemsError(null); + setHasMore(true); + setItems([]); + setItemsLoadingMore(false); + api + .fetchAccountStatuses(account, targetAccountId, PAGE_SIZE) + .then((data) => { + if (cancelled) return; + setItems(data); + setHasMore(data.length >= PAGE_SIZE); + }) + .catch((error) => { + if (cancelled) return; + setItemsError(error instanceof Error ? error.message : "게시글을 불러오지 못했습니다."); + setItems([]); + }) + .finally(() => { + if (cancelled) return; + setItemsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [account, api, targetAccountId]); + + const updateItem = useCallback((next: Status) => { + setItems((current) => current.map((item) => (item.id === next.id ? next : item))); + }, []); + + const removeItem = useCallback((statusId: string) => { + setItems((current) => current.filter((item) => item.id !== statusId)); + }, []); + + const handleToggleFavourite = useCallback( + async (target: Status) => { + if (!account) { + setItemsError("계정을 선택해 주세요."); + return; + } + setItemsError(null); + try { + const updated = target.favourited + ? await api.unfavourite(account, target.id) + : await api.favourite(account, target.id); + updateItem(updated); + } catch (error) { + setItemsError(error instanceof Error ? error.message : "좋아요 처리에 실패했습니다."); + } + }, + [account, api, updateItem] + ); + + const handleToggleReblog = useCallback( + async (target: Status) => { + if (!account) { + setItemsError("계정을 선택해 주세요."); + return; + } + setItemsError(null); + try { + const updated = target.reblogged + ? await api.unreblog(account, target.id) + : await api.reblog(account, target.id); + updateItem(updated); + } catch (error) { + setItemsError(error instanceof Error ? error.message : "부스트 처리에 실패했습니다."); + } + }, + [account, api, updateItem] + ); + + const handleDeleteStatus = useCallback( + async (target: Status) => { + if (!account) { + return; + } + setItemsError(null); + try { + await api.deleteStatus(account, target.id); + removeItem(target.id); + } catch (error) { + setItemsError(error instanceof Error ? error.message : "게시글 삭제에 실패했습니다."); + } + }, + [account, api, removeItem] + ); + + const handleReact = useCallback( + async (target: Status, reaction: ReactionInput) => { + if (!account) { + setItemsError("계정을 선택해 주세요."); + return; + } + if (account.platform !== "misskey") { + setItemsError("리액션은 미스키 계정에서만 사용할 수 있습니다."); + return; + } + if (target.myReaction && target.myReaction !== reaction.name) { + setItemsError("다른 리액션을 선택했습니다. 먼저 취소해 주세요."); + return; + } + setItemsError(null); + try { + const updated = + target.myReaction === reaction.name + ? await api.deleteReaction(account, target.id) + : await api.createReaction(account, target.id, reaction.name); + updateItem(updated); + } catch (error) { + setItemsError(error instanceof Error ? error.message : "리액션 처리에 실패했습니다."); + } + }, + [account, api, updateItem] + ); + + const loadMore = useCallback(async () => { + if (!account || !targetAccountId || itemsLoadingMore || itemsLoading || !hasMore) { + return; + } + const lastId = items[items.length - 1]?.id; + if (!lastId) { + return; + } + setItemsLoadingMore(true); + setItemsError(null); + try { + const next = await api.fetchAccountStatuses(account, targetAccountId, PAGE_SIZE, lastId); + setItems((current) => [...current, ...next]); + if (next.length < PAGE_SIZE) { + setHasMore(false); + } + } catch (error) { + setItemsError(error instanceof Error ? error.message : "게시글을 불러오지 못했습니다."); + } finally { + setItemsLoadingMore(false); + } + }, [account, api, targetAccountId, hasMore, items, itemsLoading, itemsLoadingMore]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) { + return; + } + const threshold = el.scrollHeight - el.clientHeight - 240; + if (el.scrollTop >= threshold) { + loadMore(); + } + }, [loadMore]); + + const renderTextWithEmojis = useCallback( + (text: string, keyPrefix: string, withLineBreaks: boolean) => { + const lines = withLineBreaks ? text.split(/\r?\n/) : [text]; + const nodes: React.ReactNode[] = []; + lines.forEach((line, lineIndex) => { + if (withLineBreaks && lineIndex > 0) { + nodes.push(
); + } + const tokens = + showCustomEmojis && emojiMap.size > 0 + ? tokenizeWithEmojis(line, emojiMap) + : [{ type: "text" as const, value: line }]; + tokens.forEach((token, index) => { + if (token.type === "text") { + nodes.push(...renderTextWithLinks(token.value, `${keyPrefix}-${lineIndex}-${index}`)); + } else { + nodes.push( + {`:${token.name}:`} + ); + } + }); + }); + return nodes; + }, + [emojiMap, showCustomEmojis] + ); + + const displayProfile = profile ?? buildFallbackProfile(status); + const displayName = displayProfile.name || status.accountName || status.accountHandle; + const rawHandle = displayProfile.handle || status.accountHandle; + const displayHandle = useMemo(() => { + if (!rawHandle) { + return ""; + } + if (rawHandle.includes("@")) { + return rawHandle; + } + if (displayProfile.url) { + try { + const host = new URL(displayProfile.url).hostname; + return `${rawHandle}@${host}`; + } catch { + return rawHandle; + } + } + if (account) { + return formatHandle(rawHandle, account.instanceUrl); + } + return rawHandle; + }, [account, displayProfile.url, rawHandle]); + const handleText = displayHandle ? (displayHandle.startsWith("@") ? displayHandle : `@${displayHandle}`) : ""; + const bioContent = useMemo(() => { + if (!displayProfile.bio) { + return null; + } + if (hasHtmlTags(displayProfile.bio)) { + return { type: "html" as const, value: sanitizeHtml(displayProfile.bio) }; + } + return { + type: "text" as const, + value: renderTextWithEmojis(displayProfile.bio, "profile-bio", true) + }; + }, [displayProfile.bio, renderTextWithEmojis]); + const activeHandle = account?.handle ? formatHandle(account.handle, account.instanceUrl) : account?.instanceUrl ?? ""; + + const renderFieldValue = useCallback( + (value: string, index: number) => { + if (hasHtmlTags(value)) { + return ; + } + const nodes = renderTextWithEmojis(value, `profile-field-${index}`, false); + if (isPlainUrl(value)) { + return {nodes}; + } + return {nodes}; + }, + [renderTextWithEmojis] + ); + + const renderFieldLabel = useCallback( + (label: string, index: number) => { + if (hasHtmlTags(label)) { + return ; + } + return {renderTextWithEmojis(label, `profile-field-label-${index}`, false)}; + }, + [renderTextWithEmojis] + ); + + const normalizedAccountHandle = account?.handle ? formatHandle(account.handle, account.instanceUrl) : ""; + const normalizedTargetHandle = rawHandle && account ? formatHandle(rawHandle, account.instanceUrl) : rawHandle; + const isSelfById = account?.id && targetAccountId ? account.id === targetAccountId : false; + const isSelfByHandle = + Boolean(normalizedAccountHandle && normalizedTargetHandle) && + normalizedAccountHandle === normalizedTargetHandle; + const isSelf = Boolean(isSelfById || isSelfByHandle); + const isFollowing = relationship?.following ?? false; + const isRequested = relationship?.requested ?? false; + const followState = isFollowing ? "following" : isRequested ? "requested" : "follow"; + const canFollow = Boolean(account && targetAccountId && !isSelf); + const canInteractFollow = canFollow && !followLoading; + const followLabel = + followState === "following" + ? "팔로잉" + : followState === "requested" + ? "요청됨" + : displayProfile.locked + ? "팔로우 요청" + : "팔로우"; + const followAriaLabel = + followState === "requested" + ? "팔로우 요청됨" + : followState === "following" + ? "언팔로우" + : displayProfile.locked + ? "팔로우 요청 보내기" + : "팔로우하기"; + + + + const updateRelationshipOptimistically = useCallback( + async ( + next: AccountRelationship, + action: () => Promise, + fallbackMessage: string + ) => { + if (!account || !targetAccountId) { + setFollowError("계정을 선택해 주세요."); + return; + } + const previous = relationship; + setRelationship(next); + setFollowLoading(true); + setFollowError(null); + try { + const updated = await action(); + setRelationship(updated); + setShowUnfollowConfirm(false); + } catch (error) { + setRelationship(previous); + setFollowError(error instanceof Error ? error.message : fallbackMessage); + } finally { + setFollowLoading(false); + } + }, + [account, relationship, targetAccountId] + ); + + const handleFollowClick = useCallback(() => { + if (!canInteractFollow) { + return; + } + if (!account || !targetAccountId) { + setFollowError("계정을 선택해 주세요."); + return; + } + if (followState === "following") { + setShowUnfollowConfirm(true); + return; + } + if (followState === "requested") { + updateRelationshipOptimistically( + { following: false, requested: false }, + () => api.cancelFollowRequest(account, targetAccountId), + "팔로우 요청을 취소하지 못했습니다." + ); + return; + } + const shouldRequest = displayProfile.locked; + updateRelationshipOptimistically( + { following: !shouldRequest, requested: shouldRequest }, + () => api.followAccount(account, targetAccountId), + "팔로우에 실패했습니다." + ); + }, [ + account, + api, + canInteractFollow, + displayProfile.locked, + followState, + targetAccountId, + updateRelationshipOptimistically + ]); + + const handleUnfollowConfirmed = useCallback(() => { + if (!canInteractFollow) { + return; + } + if (!account || !targetAccountId) { + setFollowError("계정을 선택해 주세요."); + return; + } + updateRelationshipOptimistically( + { following: false, requested: false }, + () => api.unfollowAccount(account, targetAccountId), + "언팔로우에 실패했습니다." + ); + }, [account, api, canInteractFollow, targetAccountId, updateRelationshipOptimistically]); + + useEffect(() => { + if (!isFollowing) { + setShowUnfollowConfirm(false); + } + }, [isFollowing]); + + return ( +
+
+
+
+

프로필

+ +
+
+
+
+
+
+
+
+ {displayProfile.avatarUrl ? ( + {`${displayName} + ) : ( +
+
+ {renderTextWithEmojis(displayName, "profile-name", false)} + {handleText ? {handleText} : null} +
+
+ {canFollow ? ( +
+
+ + {showUnfollowConfirm ? ( +
+
setShowUnfollowConfirm(false)} + /> +
+

정말 언팔로우할까요?

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

{followError}

: 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/ReactionPicker.tsx b/src/ui/components/ReactionPicker.tsx index ac00391..0c99f85 100644 --- a/src/ui/components/ReactionPicker.tsx +++ b/src/ui/components/ReactionPicker.tsx @@ -1,37 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Account, CustomEmoji, ReactionInput } from "../../domain/types"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import type { Account, 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; - } -}; +import { useClickOutside } from "../hooks/useClickOutside"; +import { useEmojiManager } from "../hooks/useEmojiManager"; export const ReactionPicker = ({ account, @@ -45,152 +16,31 @@ export const ReactionPicker = ({ 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(); + // useEmojiManager 훅 사용 + const { + emojis, + emojiStatus, + emojiError, + emojiCategories, + expandedCategories, + loadEmojis, + addToRecent, + toggleCategory + } = useEmojiManager(account, api, false); + + // 패널이 열리면 이모지 로드 useEffect(() => { - if (!instanceUrl) { - setEmojis([]); - setEmojiState("idle"); - setEmojiError(null); - return; - } - const cached = getCachedEmojis(instanceUrl); - if (cached) { - setEmojis(cached); - setEmojiState("loaded"); - setEmojiError(null); - return; + if (open && account) { + void loadEmojis(); } - setEmojis([]); - setEmojiState("idle"); - setEmojiError(null); - }, [instanceUrl]); + }, [open, account, loadEmojis]); - 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]); + useClickOutside(panelRef, open, () => setOpen(false), [buttonRef]); useEffect(() => { if (!open) { @@ -239,62 +89,26 @@ export const ReactionPicker = ({ }; }, [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; - } + (emoji) => { 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 }; - }); + addToRecent(emoji.shortcode); setOpen(false); }, - [instanceUrl, onSelect] + [onSelect, addToRecent] ); - const toggleCategory = (categoryId: string) => { - if (!instanceUrl) { - return; - } + const handleToggleCategory = (categoryId: string) => { if (categoryId === "recent") { setRecentOpen((current) => !current); - return; + } else { + toggleCategory(categoryId); } - setExpandedByInstance((current) => { - const next = new Set(current[instanceUrl] ?? []); - if (next.has(categoryId)) { - next.delete(categoryId); - } else { - next.add(categoryId); - } - return { ...current, [instanceUrl]: next }; - }); }; return ( @@ -313,7 +127,7 @@ export const ReactionPicker = ({ {open ? ( <> -
setOpen(false)} aria-hidden="true" /> +