Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7be5d07
fix: 이미지 팝업 배경 클릭 시 닫기 (#85)
deholic Jan 6, 2026
357d5a4
Merge pull request #88 from deholic/feature/image-modal-backdrop-close
deholic Jan 6, 2026
60991f3
Merge pull request #89 from deholic/feature/remove-full-handles-from-…
deholic Jan 7, 2026
08fb801
feat: 타임스탬프 클릭 시 글 팝업 모달 기능 추가
deholic Jan 7, 2026
35cabde
fix: StatusModal 테마 및 색상 모드 지원 보완
deholic Jan 7, 2026
82b0894
refine: StatusModal UI 개선
deholic Jan 7, 2026
2d48fe4
fix: StatusModal 닫기 버튼 스타일 수정
deholic Jan 7, 2026
6add41f
fix: StatusModal 닫기 버튼 다크 모드 색상 개선
deholic Jan 7, 2026
efb873d
fix: StatusModal 닫기 버튼을 일반 아이콘 버튼으로 통일
deholic Jan 7, 2026
dc7b203
feat: 게시글 팝업에서 스레드 기능 구현
deholic Jan 7, 2026
951c017
Merge pull request #91 from deholic/feature/thread-view
deholic Jan 7, 2026
e7d2060
fix: 스레드 API 호출 시 올바른 계정 정보 사용
deholic Jan 7, 2026
55d6890
Merge pull request #92 from deholic/fix/thread-account-context
deholic Jan 7, 2026
d167a89
feat: 원본 서버에서 보기 메뉴 추가
deholic Jan 7, 2026
58251ae
Merge pull request #93 from deholic/feature/show-origin-link
deholic Jan 7, 2026
ae85de2
글 작성시 글자 수 카운팅 추가
deholic Jan 8, 2026
6e65659
마스토돈 서버별 문자 수 제한 동적 적용 개선
deholic Jan 8, 2026
5f4dfe9
refactor: 글 작성상자 이미지 첨부 영역 개선
deholic Jan 8, 2026
c1eabf5
refactor: 글 작성상자 첨부 영역 스크롤 개선 및 이미지 추가 버튼 이동
deholic Jan 8, 2026
b463a5c
refactor: 테마 CSS 토큰화 및 분리
deholic Jan 8, 2026
1c5f58e
Merge pull request #96 from deholic/feature/character-count
deholic Jan 8, 2026
757bf71
feat: 이미지 팝업에 이전/다음 네비게이션 버튼 추가
deholic Jan 8, 2026
6d4542c
Merge pull request #98 from deholic/feature/image-modal-navigation-alpha
deholic Jan 8, 2026
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
112 changes: 101 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -148,6 +149,8 @@ const TimelineSection = ({
onAddSectionRight,
onRemoveSection,
onReply,
onStatusClick,
onCloseStatusModal,
onError,
onMoveSection,
canMoveLeft,
Expand All @@ -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;
Expand Down Expand Up @@ -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 : "게시글 삭제에 실패했습니다.");
}
Expand Down Expand Up @@ -563,8 +570,9 @@ const TimelineSection = ({
<TimelineItem
key={status.id}
status={status}
onReply={(item) => onReply(item, account)}
onToggleFavourite={handleToggleFavourite}
onReply={(item) => onReply(item, account)}
onStatusClick={(status) => onStatusClick(status, account)}
onToggleFavourite={handleToggleFavourite}
onToggleReblog={handleToggleReblog}
onDelete={handleDeleteStatus}
activeHandle={
Expand Down Expand Up @@ -688,8 +696,9 @@ const TimelineSection = ({
<TimelineItem
key={status.id}
status={status}
onReply={(item) => onReply(item, account)}
onToggleFavourite={handleToggleFavourite}
onReply={(item) => onReply(item, account)}
onStatusClick={(status) => onStatusClick(status, account)}
onToggleFavourite={handleToggleFavourite}
onToggleReblog={handleToggleReblog}
onDelete={handleDeleteStatus}
activeHandle={
Expand Down Expand Up @@ -822,6 +831,7 @@ export const App = () => {
[accountsState.accounts, composeAccountId]
);
const [replyTarget, setReplyTarget] = useState<Status | null>(null);
const [selectedStatus, setSelectedStatus] = useState<Status | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [oauthLoading, setOauthLoading] = useState(false);
const [mentionSeed, setMentionSeed] = useState<string | null>(null);
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1646,6 +1670,72 @@ export const App = () => {
</div>
</div>
) : null}

{selectedStatus ? (
<StatusModal
status={selectedStatus}
account={composeAccount}
threadAccount={(selectedStatus as any).__columnAccount || null}
api={services.api}
onClose={handleCloseStatusModal}
onReply={(status) => {
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}
</div>
);
};
24 changes: 24 additions & 0 deletions src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
87 changes: 86 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, 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 All @@ -132,6 +193,30 @@ export class MastodonHttpClient implements MastodonApi {
return id;
}

async fetchContext(account: Account, statusId: string): Promise<ThreadContext> {
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<string, unknown>;

// 마스토돈 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<Status> {
const response = await fetch(`${account.instanceUrl}/api/v1/statuses`, {
method: "POST",
Expand Down
55 changes: 54 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, 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 All @@ -158,6 +179,38 @@ export class MisskeyHttpClient implements MastodonApi {
return id;
}

async fetchConversation(account: Account, noteId: string): Promise<ThreadContext> {
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<Status> {
const payload: Record<string, unknown> = {
text: input.status,
Expand Down
Loading
Loading