Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
63 changes: 62 additions & 1 deletion src/infra/MastodonHttpClient.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -111,6 +111,67 @@ export class MastodonHttpClient implements MastodonApi {
return mapCustomEmojis(data);
}

async fetchInstanceInfo(account: Account): Promise<InstanceInfo> {
// 먼저 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<string, unknown>;
const configuration = v2Data.configuration as Record<string, unknown> || {};
const statuses = configuration.statuses as Record<string, unknown> || {};

// 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<string, unknown>;

// 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<string, unknown>;
if (config.statuses && typeof config.statuses === "object") {
const statuses = config.statuses as Record<string, unknown>;
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<string> {
const formData = new FormData();
formData.append("file", file);
Expand Down
23 changes: 22 additions & 1 deletion src/infra/MisskeyHttpClient.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -136,6 +136,27 @@ export class MisskeyHttpClient implements MastodonApi {
return mapMisskeyEmojis(data);
}

async fetchInstanceInfo(account: Account): Promise<InstanceInfo> {
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<string, unknown>;
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<string> {
const formData = new FormData();
formData.append("i", account.accessToken);
Expand Down
7 changes: 6 additions & 1 deletion src/infra/UnifiedApiClient.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -28,6 +28,11 @@ export class UnifiedApiClient implements MastodonApi {
return this.getClient(account).fetchCustomEmojis(account);
}

fetchInstanceInfo(account: Account): Promise<InstanceInfo> {
const client = this.getClient(account) as any;
return client.fetchInstanceInfo(account);
}

uploadMedia(account: Account, file: File) {
return this.getClient(account).uploadMedia(account, file);
}
Expand Down
3 changes: 2 additions & 1 deletion src/services/MastodonApi.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -21,4 +21,5 @@ export interface MastodonApi {
unfavourite(account: Account, statusId: string): Promise<Status>;
reblog(account: Account, statusId: string): Promise<Status>;
unreblog(account: Account, statusId: string): Promise<Status>;
fetchInstanceInfo(account: Account): Promise<InstanceInfo>;
}
166 changes: 119 additions & 47 deletions src/ui/components/ComposeBox.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -100,6 +107,10 @@ export const ComposeBox = ({
const [recentByInstance, setRecentByInstance] = useState<Record<string, string[]>>({});
const [expandedByInstance, setExpandedByInstance] = useState<Record<string, Set<string>>>({});
const [recentOpen, setRecentOpen] = useState(true);

// 문자 수 관련 상태
const [characterLimit, setCharacterLimit] = useState<number | null>(null);
const [instanceLoading, setInstanceLoading] = useState(false);
const activeImage = useMemo(
() => attachments.find((item) => item.id === activeImageId) ?? null,
[attachments, activeImageId]
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -438,40 +495,68 @@ export const ComposeBox = ({
/>
</div>
) : null}
<textarea
ref={textareaRef}
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="지금 무슨 생각을 하고 있나요?"
rows={4}
onPaste={handlePaste}
disabled={isSubmitting}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
void submitPost();
}
}}
/>
{attachments.length > 0 ? (
<div className="compose-input-container">
<textarea
ref={textareaRef}
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="지금 무슨 생각을 하고 있나요?"
rows={4}
onPaste={handlePaste}
disabled={isSubmitting}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
void submitPost();
}
}}
/>
<div className="compose-attachments">
{attachments.map((item) => (
<button
key={item.id}
type="button"
className="attachment-thumb"
onClick={() => {
setImageZoom(1);
setImageOffset({ x: 0, y: 0 });
setActiveImageId(item.id);
}}
aria-label="이미지 미리보기"
>
<img src={item.previewUrl} alt="선택한 이미지" loading="lazy" />
</button>
))}
{/* 첨부된 이미지들과 이미지 추가 버튼 */}
<div className="compose-attachments-scroll">
{attachments.map((item) => (
<button
key={item.id}
type="button"
className="attachment-thumb"
onClick={() => {
setImageZoom(1);
setImageOffset({ x: 0, y: 0 });
setActiveImageId(item.id);
}}
aria-label="이미지 미리보기"
>
<img src={item.previewUrl} alt="선택한 이미지" loading="lazy" />
</button>
))}

{/* 이미지 추가 버튼 */}
<label className="file-button attachment-thumb" aria-label="이미지 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" ry="2" />
<circle cx="9" cy="10" r="2" />
<path d="M21 16l-5-5-4 4-2-2-5 5" />
</svg>
<input
type="file"
accept="image/*"
multiple
onChange={handleFilesSelected}
disabled={isSubmitting}
/>
</label>
</div>

{/* 문자 수 표시 - 고정 위치 */}
{characterLimit && (
<div className="compose-attachments-character-count">
<span className={getCharacterCountClassName(charCountStatus)}>
{currentCharCount.toLocaleString()} / {characterLimit.toLocaleString()}
</span>
</div>
)}
</div>
) : null}
</div>
<div className="compose-actions">
<select
value={visibility}
Expand All @@ -484,6 +569,7 @@ export const ComposeBox = ({
</option>
))}
</select>

<div className="compose-actions-right">
<button
type="button"
Expand All @@ -509,20 +595,6 @@ export const ComposeBox = ({
>
CW
</button>
<label className="file-button icon-button compose-icon-button" aria-label="이미지 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" ry="2" />
<circle cx="9" cy="10" r="2" />
<path d="M21 16l-5-5-4 4-2-2-5 5" />
</svg>
<input
type="file"
accept="image/*"
multiple
onChange={handleFilesSelected}
disabled={isSubmitting}
/>
</label>
<button
type="submit"
className="icon-button compose-icon-button"
Expand Down
Loading