diff --git a/src/App.tsx b/src/App.tsx index 16de8c3..5355e2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import type { Account, Status, TimelineType } from "./domain/types"; import { AccountAdd } from "./ui/components/AccountAdd"; import { AccountSelector } from "./ui/components/AccountSelector"; 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 { useAppContext } from "./ui/state/AppContext"; @@ -148,6 +149,8 @@ const TimelineSection = ({ onAddSectionRight, onRemoveSection, onReply, + onStatusClick, + onCloseStatusModal, onError, onMoveSection, canMoveLeft, @@ -170,8 +173,11 @@ const TimelineSection = ({ onAddSectionRight: (sectionId: string) => void; onRemoveSection: (sectionId: string) => void; onReply: (status: Status, account: Account | null) => void; + onStatusClick: (status: Status, columnAccount: Account | null) => void; onError: (message: string | null) => void; + columnAccount: Account | null; onMoveSection: (sectionId: string, direction: "left" | "right") => void; + onCloseStatusModal: () => void; canMoveLeft: boolean; canMoveRight: boolean; canRemoveSection: boolean; @@ -422,6 +428,7 @@ const TimelineSection = ({ try { await services.api.deleteStatus(account, status.id); timeline.removeItem(status.id); + onCloseStatusModal(); } catch (err) { onError(err instanceof Error ? err.message : "게시글 삭제에 실패했습니다."); } @@ -563,8 +570,9 @@ const TimelineSection = ({ onReply(item, account)} - onToggleFavourite={handleToggleFavourite} + onReply={(item) => onReply(item, account)} + onStatusClick={(status) => onStatusClick(status, account)} + onToggleFavourite={handleToggleFavourite} onToggleReblog={handleToggleReblog} onDelete={handleDeleteStatus} activeHandle={ @@ -688,8 +696,9 @@ const TimelineSection = ({ onReply(item, account)} - onToggleFavourite={handleToggleFavourite} + onReply={(item) => onReply(item, account)} + onStatusClick={(status) => onStatusClick(status, account)} + onToggleFavourite={handleToggleFavourite} onToggleReblog={handleToggleReblog} onDelete={handleDeleteStatus} activeHandle={ @@ -822,6 +831,7 @@ export const App = () => { [accountsState.accounts, composeAccountId] ); const [replyTarget, setReplyTarget] = useState(null); + const [selectedStatus, setSelectedStatus] = useState(null); const [actionError, setActionError] = useState(null); const [oauthLoading, setOauthLoading] = useState(false); const [mentionSeed, setMentionSeed] = useState(null); @@ -1188,6 +1198,17 @@ export const App = () => { setComposeAccountId(account.id); setReplyTarget(status); setMentionSeed(`@${status.accountHandle}`); + setSelectedStatus(null); + }; + + const handleStatusClick = (status: Status, columnAccount: Account | null) => { + setSelectedStatus(status); + // Status에 columnAccount 정보를 임시 저장 + (status as any).__columnAccount = columnAccount; + }; + + const handleCloseStatusModal = () => { + setSelectedStatus(null); }; const composeAccountSelector = ( @@ -1413,13 +1434,16 @@ export const App = () => { account={sectionAccount} services={services} accountsState={accountsState} - onAccountChange={setSectionAccount} - onTimelineChange={setSectionTimeline} - onAddSectionLeft={(id) => addSectionNear(id, "left")} - onAddSectionRight={(id) => addSectionNear(id, "right")} - onRemoveSection={removeSection} - onReply={handleReply} - onError={(message) => setActionError(message || null)} +onAccountChange={setSectionAccount} + onTimelineChange={setSectionTimeline} + onAddSectionLeft={(id) => addSectionNear(id, "left")} + onAddSectionRight={(id) => addSectionNear(id, "right")} + onRemoveSection={removeSection} + onReply={handleReply} + onStatusClick={handleStatusClick} + columnAccount={sectionAccount} + onCloseStatusModal={handleCloseStatusModal} + onError={(message) => setActionError(message || null)} onMoveSection={moveSection} canMoveLeft={index > 0} canMoveRight={index < sections.length - 1} @@ -1646,6 +1670,72 @@ export const App = () => { ) : null} + + {selectedStatus ? ( + { + if (composeAccount) { + handleReply(status, composeAccount); + } + }} + onToggleFavourite={async (status) => { + if (!composeAccount) { + setActionError("계정을 선택해주세요."); + return; + } + setActionError(null); + try { + const updated = status.favourited + ? await services.api.unfavourite(composeAccount, status.id) + : await services.api.favourite(composeAccount, status.id); + // Update the status in modal + setSelectedStatus(updated); + } catch (err) { + setActionError(err instanceof Error ? err.message : "좋아요 처리에 실패했습니다."); + } + }} + onToggleReblog={async (status) => { + if (!composeAccount) { + setActionError("계정을 선택해주세요."); + return; + } + setActionError(null); + try { + const updated = status.reblogged + ? await services.api.unreblog(composeAccount, status.id) + : await services.api.reblog(composeAccount, status.id); + setSelectedStatus(updated); + } catch (err) { + setActionError(err instanceof Error ? err.message : "부스트 처리에 실패했습니다."); + } + }} + onDelete={async (status) => { + if (!composeAccount) { + return; + } + setActionError(null); + try { + await services.api.deleteStatus(composeAccount, status.id); + setSelectedStatus(null); + } catch (err) { + setActionError(err instanceof Error ? err.message : "게시글 삭제에 실패했습니다."); + } + }} + activeHandle={ + composeAccount?.handle ? formatHandle(composeAccount.handle, composeAccount.instanceUrl) : composeAccount?.instanceUrl ?? "" + } + activeAccountHandle={composeAccount?.handle ?? ""} + activeAccountUrl={composeAccount?.url ?? null} + showProfileImage={showProfileImages} + showCustomEmojis={showCustomEmojis} + showReactions={showMisskeyReactions} + /> + ) : null} ); }; diff --git a/src/domain/types.ts b/src/domain/types.ts index 6ee853d..8b7529d 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -96,6 +96,30 @@ export type Status = { accountEmojis: CustomEmoji[]; }; +export type ThreadContext = { + ancestors: Status[]; + descendants: Status[]; + conversation?: Status[]; // Misskey 전체 대화용 (시간순 정렬) +}; + export type TimelineItem = { status: Status; }; + +export type InstanceInfo = { + // 공통 필드 + uri: string; + title: string; + description?: string; + + // Mastodon 전용 + max_toot_chars?: number; + + // Misskey 전용 + maxNoteLength?: number; + + // 플랫폼 식별 + platform: AccountPlatform; +}; + +export type CharacterCountStatus = "normal" | "warning" | "limit"; diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index 3f1d014..1e22ced 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -1,4 +1,4 @@ -import type { Account, CustomEmoji, Status, TimelineType } from "../domain/types"; +import type { Account, CustomEmoji, Status, ThreadContext, TimelineType, InstanceInfo } from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; import { mapNotificationToStatus, mapStatus } from "./mastodonMapper"; @@ -111,6 +111,67 @@ export class MastodonHttpClient implements MastodonApi { return mapCustomEmojis(data); } + async fetchInstanceInfo(account: Account): Promise { + // 먼저 v2 API를 시도 + try { + const v2Response = await fetch(`${account.instanceUrl}/api/v2/instance`, { + headers: buildHeaders(account) + }); + if (v2Response.ok) { + const v2Data = (await v2Response.json()) as Record; + const configuration = v2Data.configuration as Record || {}; + const statuses = configuration.statuses as Record || {}; + + // v2 API에서 max_characters 가져오기, 없으면 v1 호환 필드 사용 + const maxChars = typeof statuses.max_characters === "number" + ? statuses.max_characters + : typeof v2Data.max_toot_chars === "number" + ? v2Data.max_toot_chars + : 500; + + return { + uri: String(v2Data.uri || v2Data.domain || ""), + title: String(v2Data.title || ""), + description: v2Data.description ? String(v2Data.description) : undefined, + max_toot_chars: maxChars, + platform: "mastodon" + }; + } + } catch { + // v2 API 실패 시 v1 API로 fallback + } + + const v1Response = await fetch(`${account.instanceUrl}/api/v1/instance`, { + headers: buildHeaders(account) + }); + if (!v1Response.ok) { + throw new Error("인스턴스 정보를 불러오지 못했습니다."); + } + const data = (await v1Response.json()) as Record; + + // v1 API에서 configuration 확인 (이전 버전과의 호환성) + let maxChars = 500; + if (typeof data.max_toot_chars === "number") { + maxChars = data.max_toot_chars; + } else if (data.configuration && typeof data.configuration === "object") { + const config = data.configuration as Record; + if (config.statuses && typeof config.statuses === "object") { + const statuses = config.statuses as Record; + if (typeof statuses.max_characters === "number") { + maxChars = statuses.max_characters; + } + } + } + + return { + uri: String(data.uri || data.domain || ""), + title: String(data.title || ""), + description: data.description ? String(data.description) : undefined, + max_toot_chars: maxChars, + platform: "mastodon" + }; + } + async uploadMedia(account: Account, file: File): Promise { const formData = new FormData(); formData.append("file", file); @@ -132,6 +193,30 @@ export class MastodonHttpClient implements MastodonApi { return id; } + async fetchContext(account: Account, statusId: string): Promise { + const response = await fetch(`${account.instanceUrl}/api/v1/statuses/${statusId}/context`, { + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("스레드 컨텍스트를 불러오지 못했습니다."); + } + const data = (await response.json()) as Record; + + // 마스토돈 API 응답: { ancestors: Status[], descendants: Status[] } + const ancestors = Array.isArray(data.ancestors) + ? data.ancestors.map(mapStatus).filter((status): status is Status => status !== null) + : []; + + const descendants = Array.isArray(data.descendants) + ? data.descendants.map(mapStatus).filter((status): status is Status => status !== null) + : []; + + return { + ancestors, + descendants + }; + } + async createStatus(account: Account, input: CreateStatusInput): Promise { const response = await fetch(`${account.instanceUrl}/api/v1/statuses`, { method: "POST", diff --git a/src/infra/MisskeyHttpClient.ts b/src/infra/MisskeyHttpClient.ts index 0397ef4..6316536 100644 --- a/src/infra/MisskeyHttpClient.ts +++ b/src/infra/MisskeyHttpClient.ts @@ -1,4 +1,4 @@ -import type { Account, CustomEmoji, Status, TimelineType } from "../domain/types"; +import type { Account, CustomEmoji, Status, ThreadContext, TimelineType, InstanceInfo } from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; import { mapMisskeyNotification, mapMisskeyStatusWithInstance } from "./misskeyMapper"; @@ -136,6 +136,27 @@ export class MisskeyHttpClient implements MastodonApi { return mapMisskeyEmojis(data); } + async fetchInstanceInfo(account: Account): Promise { + const response = await fetch(`${normalizeInstanceUrl(account.instanceUrl)}/api/meta`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(buildBody(account, {})) + }); + if (!response.ok) { + throw new Error("인스턴스 정보를 불러오지 못했습니다."); + } + const data = (await response.json()) as Record; + return { + uri: String(data.uri || ""), + title: String(data.name || ""), + description: data.description ? String(data.description) : undefined, + maxNoteLength: typeof data.maxNoteLength === "number" ? data.maxNoteLength : 3000, + platform: "misskey" + }; + } + async uploadMedia(account: Account, file: File): Promise { const formData = new FormData(); formData.append("i", account.accessToken); @@ -158,6 +179,38 @@ export class MisskeyHttpClient implements MastodonApi { return id; } + async fetchConversation(account: Account, noteId: string): Promise { + const response = await fetch(`${normalizeInstanceUrl(account.instanceUrl)}/api/notes/conversation`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(buildBody(account, { noteId, limit: 100 })) + }); + if (!response.ok) { + throw new Error("대화를 불러오지 못했습니다."); + } + const data = (await response.json()) as unknown[]; + + // 미스키는 시간순으로 정렬된 전체 대화를 반환 + const conversation = data + .map((item) => mapMisskeyStatusWithInstance(item, account.instanceUrl)) + .filter((status): status is Status => status !== null); + + // 전체 대화에서 현재 노트를 찾아서 ancestors/descendants로 분리 + const currentIndex = conversation.findIndex(status => status.id === noteId); + const ancestors = currentIndex > 0 ? conversation.slice(0, currentIndex) : []; + const descendants = currentIndex >= 0 && currentIndex < conversation.length - 1 + ? conversation.slice(currentIndex + 1) + : []; + + return { + ancestors, + descendants, + conversation // 미스키 전용: 전체 대화 보존 + }; + } + async createStatus(account: Account, input: CreateStatusInput): Promise { const payload: Record = { text: input.status, diff --git a/src/infra/UnifiedApiClient.ts b/src/infra/UnifiedApiClient.ts index c387e5e..a7eb1c0 100644 --- a/src/infra/UnifiedApiClient.ts +++ b/src/infra/UnifiedApiClient.ts @@ -1,4 +1,4 @@ -import type { Account, TimelineType } from "../domain/types"; +import type { Account, ThreadContext, TimelineType, InstanceInfo } from "../domain/types"; import type { CustomEmoji } from "../domain/types"; import type { CreateStatusInput, MastodonApi } from "../services/MastodonApi"; @@ -28,6 +28,11 @@ export class UnifiedApiClient implements MastodonApi { return this.getClient(account).fetchCustomEmojis(account); } + fetchInstanceInfo(account: Account): Promise { + const client = this.getClient(account) as any; + return client.fetchInstanceInfo(account); + } + uploadMedia(account: Account, file: File) { return this.getClient(account).uploadMedia(account, file); } @@ -55,4 +60,16 @@ export class UnifiedApiClient implements MastodonApi { unreblog(account: Account, statusId: string) { return this.getClient(account).unreblog(account, statusId); } + + async fetchThreadContext(account: Account, statusId: string): Promise { + if (account.platform === "misskey") { + // MisskeyHttpClient에는 fetchConversation 메서드가 있음 + const misskeyClient = this.getClient(account) as any; + return misskeyClient.fetchConversation(account, statusId); + } else { + // MastodonHttpClient에는 fetchContext 메서드가 있음 + const mastodonClient = this.getClient(account) as any; + return mastodonClient.fetchContext(account, statusId); + } + } } diff --git a/src/services/MastodonApi.ts b/src/services/MastodonApi.ts index b2cc7e3..702a00c 100644 --- a/src/services/MastodonApi.ts +++ b/src/services/MastodonApi.ts @@ -1,4 +1,4 @@ -import type { Account, Status, TimelineType, Visibility } from "../domain/types"; +import type { Account, Status, TimelineType, Visibility, InstanceInfo } from "../domain/types"; import type { CustomEmoji } from "../domain/types"; export type CreateStatusInput = { @@ -21,4 +21,5 @@ export interface MastodonApi { unfavourite(account: Account, statusId: string): Promise; reblog(account: Account, statusId: string): Promise; unreblog(account: Account, statusId: string): Promise; + fetchInstanceInfo(account: Account): Promise; } diff --git a/src/ui/components/ComposeBox.tsx b/src/ui/components/ComposeBox.tsx index baeaa3f..5976ce5 100644 --- a/src/ui/components/ComposeBox.tsx +++ b/src/ui/components/ComposeBox.tsx @@ -1,6 +1,13 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Account, CustomEmoji, Visibility } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; +import { + calculateCharacterCount, + getCharacterLimit, + getCharacterCountStatus, + getCharacterCountClassName, + getDefaultCharacterLimit +} from "../utils/characterCount"; const VISIBILITY_KEY = "textodon.compose.visibility"; @@ -100,6 +107,10 @@ export const ComposeBox = ({ const [recentByInstance, setRecentByInstance] = useState>({}); const [expandedByInstance, setExpandedByInstance] = useState>>({}); const [recentOpen, setRecentOpen] = useState(true); + + // 문자 수 관련 상태 + const [characterLimit, setCharacterLimit] = useState(null); + const [instanceLoading, setInstanceLoading] = useState(false); const activeImage = useMemo( () => attachments.find((item) => item.id === activeImageId) ?? null, [attachments, activeImageId] @@ -200,10 +211,56 @@ export const ComposeBox = ({ localStorage.setItem(VISIBILITY_KEY, visibility); }, [visibility]); + // 계정 변경 시 인스턴스 정보 로드 + useEffect(() => { + if (!account) { + setCharacterLimit(null); + return; + } + + const loadInstanceInfo = async () => { + try { + setInstanceLoading(true); + const instanceInfo = await api.fetchInstanceInfo(account); + const limit = getCharacterLimit(instanceInfo); + setCharacterLimit(limit); + } catch (error) { + console.error("인스턴스 정보 로드 실패:", error); + // fallback: 기본값 사용 + const fallbackLimit = getDefaultCharacterLimit(account.platform); + setCharacterLimit(fallbackLimit); + } finally { + setInstanceLoading(false); + } + }; + + loadInstanceInfo(); + }, [account, api]); + + // 현재 문자 수 계산 + const currentCharCount = useMemo(() => { + if (!account) return 0; + const fullText = (cwEnabled ? cwText + "\n" : "") + text; + return calculateCharacterCount(fullText, account.platform); + }, [text, cwText, cwEnabled, account]); + + // 문자 수 상태 계산 + const charCountStatus = useMemo(() => { + if (!characterLimit) return "normal"; + return getCharacterCountStatus(currentCharCount, characterLimit); + }, [currentCharCount, characterLimit]); + const submitPost = async () => { if (!text.trim() || isSubmitting) { return; } + + // 문자 수 제한 검사 + if (characterLimit && currentCharCount > characterLimit) { + alert(`글자 수 제한(${characterLimit.toLocaleString()}자)을 초과했습니다.`); + return; + } + setIsSubmitting(true); try { const ok = await onSubmit({ @@ -438,40 +495,68 @@ export const ComposeBox = ({ /> ) : null} -