From ae85de20ffc6dfc1fe1da2d457f9d7ec152aa257 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Thu, 8 Jan 2026 09:27:26 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=84=B1=EC=8B=9C=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=20=EC=88=98=20=EC=B9=B4=EC=9A=B4=ED=8C=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인 타입에 InstanceInfo와 CharacterCountStatus 추가 - 플랫폼별 문자 수 계산 유틸리티 구현 (Mastodon: URL 23자 계산, Misskey: 순수 텍스트) - MastodonHttpClient와 MisskeyHttpClient에 fetchInstanceInfo 메서드 추가 - UnifiedApiClient에 인스턴스 정보 조회 기능 통합 - ComposeBox 컴포넌트에 실시간 문자 수 표시 UI 추가 - 문자 수 제한 초과 시 제출 방지 및 알림 기능 - 라이트/다크 테마별 문자 수 색상 스타일링 - 인스턴스별 동적 문자 수 제한 적용 (Mastodon: 500자, Misskey: 3000자 기본값) Fixes #94 --- src/domain/types.ts | 18 +++++++++ src/infra/MastodonHttpClient.ts | 19 ++++++++- src/infra/MisskeyHttpClient.ts | 23 ++++++++++- src/infra/UnifiedApiClient.ts | 7 +++- src/services/MastodonApi.ts | 3 +- src/ui/components/ComposeBox.tsx | 69 +++++++++++++++++++++++++++++++- src/ui/styles/components.css | 20 +++++++++ src/ui/styles/theme.css | 31 ++++++++++++++ src/ui/utils/characterCount.ts | 60 +++++++++++++++++++++++++++ 9 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 src/ui/utils/characterCount.ts 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..5cd3321 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,23 @@ export class MastodonHttpClient implements MastodonApi { return mapCustomEmojis(data); } + async fetchInstanceInfo(account: Account): Promise { + const response = await fetch(`${account.instanceUrl}/api/v1/instance`, { + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("인스턴스 정보를 불러오지 못했습니다."); + } + const data = (await response.json()) as Record; + return { + uri: String(data.uri || data.domain || ""), + title: String(data.title || ""), + description: data.description ? String(data.description) : undefined, + max_toot_chars: typeof data.max_toot_chars === "number" ? data.max_toot_chars : 500, + 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..13fabb5 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({ @@ -484,6 +541,16 @@ export const ComposeBox = ({ ))} + + {/* 문자 수 표시 */} + {characterLimit && ( +
+ + {currentCharCount.toLocaleString()} / {characterLimit.toLocaleString()} + +
+ )} +
) : null} -