diff --git a/src/domain/types.ts b/src/domain/types.ts index cdf1c2f..8b7529d 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -105,3 +105,21 @@ export type ThreadContext = { 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 5b4626f..1e22ced 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -1,4 +1,4 @@ -import type { Account, CustomEmoji, Status, ThreadContext, 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); diff --git a/src/infra/MisskeyHttpClient.ts b/src/infra/MisskeyHttpClient.ts index ff9f964..6316536 100644 --- a/src/infra/MisskeyHttpClient.ts +++ b/src/infra/MisskeyHttpClient.ts @@ -1,4 +1,4 @@ -import type { Account, CustomEmoji, Status, ThreadContext, 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); diff --git a/src/infra/UnifiedApiClient.ts b/src/infra/UnifiedApiClient.ts index c9da4f5..a7eb1c0 100644 --- a/src/infra/UnifiedApiClient.ts +++ b/src/infra/UnifiedApiClient.ts @@ -1,4 +1,4 @@ -import type { Account, ThreadContext, 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); } 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 49d77a3..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} -