From 435b9b256ab738ad35c3442750bdf37cd7ff462f Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Thu, 15 Jan 2026 11:19:50 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EB=A7=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=8F=88=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=EC=97=90=20?= =?UTF-8?q?=EB=B6=81=EB=A7=88=ED=81=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MastodonApi 인터페이스에 bookmark/unbookmark 메서드 추가 - MastodonHttpClient에서 API 호출 구현 (POST /api/v1/statuses/:id/bookmark) - Status 타입에 bookmarked 필드 추가 - TimelineItem 메뉴에 북마크/북마크 취소 버튼 추가 - App.tsx, StatusModal.tsx, ProfileModal.tsx에 핸들러 구현 - 낙관적 업데이트와 실패 롤백 로직 포함 - 성공 시 토스트 메시지 표시 기능 추가 - UnifiedApiClient에 북마크 메서드 라우팅 추가 --- src/App.tsx | 59 ++++++++++++++++++++++++++++-- src/domain/types.ts | 1 + src/infra/MastodonHttpClient.ts | 12 ++++++ src/infra/UnifiedApiClient.ts | 8 ++++ src/infra/mastodonMapper.ts | 4 ++ src/services/MastodonApi.ts | 5 ++- src/ui/components/ProfileModal.tsx | 28 ++++++++++++-- src/ui/components/StatusModal.tsx | 17 ++++++--- src/ui/components/TimelineItem.tsx | 18 +++++++-- 9 files changed, 135 insertions(+), 17 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index fa7b957..1f4a7b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -529,6 +529,34 @@ const TimelineSection = ({ } }; + const handleToggleBookmark = async (status: Status) => { + if (!account) { + onError("계정을 선택해주세요."); + return; + } + onError(null); + const isBookmarking = !status.bookmarked; + const optimistic = { + ...status, + bookmarked: isBookmarking + }; + timeline.updateItem(optimistic); + try { + const updated = status.bookmarked + ? await services.api.unbookmark(account, status.id) + : await services.api.bookmark(account, status.id); + timeline.updateItem(updated); + if (isBookmarking) { + showToast("북마크했습니다."); + } else { + showToast("북마크를 취소했습니다."); + } + } catch (err) { + onError(err instanceof Error ? err.message : "북마크 처리에 실패했습니다."); + timeline.updateItem(status); + } + }; + const handleReact = useCallback( (status: Status, reaction: ReactionInput) => { onReact(account, status, reaction); @@ -678,8 +706,9 @@ const TimelineSection = ({ onReply={(item) => onReply(item, account)} onStatusClick={(status) => onStatusClick(status, account)} onToggleFavourite={handleToggleFavourite} - onToggleReblog={handleToggleReblog} - onDelete={handleDeleteStatus} + onToggleReblog={handleToggleReblog} + onToggleBookmark={handleToggleBookmark} + onDelete={handleDeleteStatus} onReact={handleReact} onProfileClick={(item) => onProfileClick(item, account)} activeHandle={ @@ -810,8 +839,9 @@ const TimelineSection = ({ onReply={(item) => onReply(item, account)} onStatusClick={(status) => onStatusClick(status, account)} onToggleFavourite={handleToggleFavourite} - onToggleReblog={handleToggleReblog} - onDelete={handleDeleteStatus} + onToggleReblog={handleToggleReblog} + onToggleBookmark={handleToggleBookmark} + onDelete={handleDeleteStatus} onReact={handleReact} onProfileClick={(item) => onProfileClick(item, account)} activeHandle={ @@ -2067,6 +2097,27 @@ onAccountChange={setSectionAccount} setActionError(err instanceof Error ? err.message : "부스트 처리에 실패했습니다."); } }} + onToggleBookmark={async (status) => { + if (!composeAccount) { + setActionError("계정을 선택해주세요."); + return; + } + setActionError(null); + const isBookmarking = !status.bookmarked; + try { + const updated = status.bookmarked + ? await services.api.unbookmark(composeAccount, status.id) + : await services.api.bookmark(composeAccount, status.id); + setSelectedStatus(updated); + if (isBookmarking) { + showToast("북마크했습니다."); + } else { + showToast("북마크를 취소했습니다."); + } + } catch (err) { + setActionError(err instanceof Error ? err.message : "북마크 처리에 실패했습니다."); + } + }} onDelete={async (status) => { if (!composeAccount) { return; diff --git a/src/domain/types.ts b/src/domain/types.ts index 6eee19a..c36276f 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -94,6 +94,7 @@ export type Status = { reactions: Reaction[]; reblogged: boolean; favourited: boolean; + bookmarked: boolean; inReplyToId: string | null; mentions: Mention[]; mediaAttachments: MediaAttachment[]; diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index bb141e0..807f329 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -283,6 +283,10 @@ export class MastodonHttpClient implements MastodonApi { return mapAccountRelationship(data); } + async fetchThreadContext(account: Account, statusId: string): Promise { + return this.fetchContext(account, statusId); + } + async fetchAccountStatuses( account: Account, accountId: string, @@ -386,6 +390,14 @@ export class MastodonHttpClient implements MastodonApi { return this.postAction(account, statusId, "unfavourite"); } + async bookmark(account: Account, statusId: string): Promise { + return this.postAction(account, statusId, "bookmark"); + } + + async unbookmark(account: Account, statusId: string): Promise { + return this.postAction(account, statusId, "unbookmark"); + } + async createReaction(_account: Account, _statusId: string, _reaction: string): Promise { throw new Error("리액션은 미스키 계정에서만 사용할 수 있습니다."); } diff --git a/src/infra/UnifiedApiClient.ts b/src/infra/UnifiedApiClient.ts index df9d50e..c6139df 100644 --- a/src/infra/UnifiedApiClient.ts +++ b/src/infra/UnifiedApiClient.ts @@ -95,6 +95,14 @@ export class UnifiedApiClient implements MastodonApi { return this.getClient(account).unfavourite(account, statusId); } + bookmark(account: Account, statusId: string) { + return this.getClient(account).bookmark(account, statusId); + } + + unbookmark(account: Account, statusId: string) { + return this.getClient(account).unbookmark(account, statusId); + } + createReaction(account: Account, statusId: string, reaction: string) { return this.getClient(account).createReaction(account, statusId, reaction); } diff --git a/src/infra/mastodonMapper.ts b/src/infra/mastodonMapper.ts index 19168f6..ba315be 100644 --- a/src/infra/mastodonMapper.ts +++ b/src/infra/mastodonMapper.ts @@ -259,6 +259,7 @@ export const mapStatus = (raw: unknown): Status => { reactions, reblogged: Boolean(value.reblogged ?? false), favourited: Boolean(value.favourited ?? false), + bookmarked: Boolean(value.bookmarked ?? false), inReplyToId: value.in_reply_to_id ? String(value.in_reply_to_id) : null, mentions: mapMentions(value.mentions), mediaAttachments: mapMediaAttachments(value.media_attachments), @@ -327,6 +328,8 @@ export const mapNotificationToStatus = (raw: unknown): Status | null => { accountUrl, accountAvatarUrl, content, + htmlContent: "", + hasRichContent: false, url: target?.url ?? null, visibility: target?.visibility ?? "public", spoilerText: "", @@ -338,6 +341,7 @@ export const mapNotificationToStatus = (raw: unknown): Status | null => { reactions: [], reblogged: false, favourited: false, + bookmarked: false, inReplyToId: target?.inReplyToId ?? null, mentions: [], mediaAttachments: target?.mediaAttachments ?? [], diff --git a/src/services/MastodonApi.ts b/src/services/MastodonApi.ts index c181188..ccb56a3 100644 --- a/src/services/MastodonApi.ts +++ b/src/services/MastodonApi.ts @@ -1,4 +1,4 @@ -import type { Account, AccountRelationship, Status, TimelineType, Visibility, InstanceInfo, UserProfile } from "../domain/types"; +import type { Account, AccountRelationship, Status, TimelineType, Visibility, InstanceInfo, UserProfile, ThreadContext } from "../domain/types"; import type { CustomEmoji } from "../domain/types"; export type CreateStatusInput = { @@ -21,6 +21,8 @@ export interface MastodonApi { deleteStatus(account: Account, statusId: string): Promise; favourite(account: Account, statusId: string): Promise; unfavourite(account: Account, statusId: string): Promise; + bookmark(account: Account, statusId: string): Promise; + unbookmark(account: Account, statusId: string): Promise; createReaction(account: Account, statusId: string, reaction: string): Promise; deleteReaction(account: Account, statusId: string): Promise; reblog(account: Account, statusId: string): Promise; @@ -36,4 +38,5 @@ export interface MastodonApi { blockAccount(account: Account, accountId: string): Promise; unblockAccount(account: Account, accountId: string): Promise; fetchAccountStatuses(account: Account, accountId: string, limit: number, maxId?: string): Promise; + fetchThreadContext(account: Account, statusId: string): Promise; } diff --git a/src/ui/components/ProfileModal.tsx b/src/ui/components/ProfileModal.tsx index d77a481..f65c606 100644 --- a/src/ui/components/ProfileModal.tsx +++ b/src/ui/components/ProfileModal.tsx @@ -365,6 +365,27 @@ export const ProfileModal = ({ [account, api, updateItem] ); + const handleToggleBookmark = useCallback( + async (target: Status) => { + if (!account) { + setItemsError("계정을 선택해 주세요."); + return; + } + setItemsError(null); + const isBookmarking = !target.bookmarked; + try { + const updated = target.bookmarked + ? await api.unbookmark(account, target.id) + : await api.bookmark(account, target.id); + updateItem(updated); + showToast(isBookmarking ? "북마크했습니다." : "북마크를 취소했습니다."); + } catch (error) { + setItemsError(error instanceof Error ? error.message : "북마크 처리에 실패했습니다."); + } + }, + [account, api, updateItem, showToast] + ); + const handleDeleteStatus = useCallback( async (target: Status) => { if (!account) { @@ -959,9 +980,10 @@ export const ProfileModal = ({ key={item.id} status={item} onReply={(target) => onReply(target, account)} - onToggleFavourite={handleToggleFavourite} - onToggleReblog={handleToggleReblog} - onDelete={handleDeleteStatus} + onToggleFavourite={handleToggleFavourite} + onToggleReblog={handleToggleReblog} + onToggleBookmark={handleToggleBookmark} + onDelete={handleDeleteStatus} onReact={handleReact} onStatusClick={onStatusClick} onProfileClick={(target) => onProfileClick(target, account)} diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx index d65a77f..3c28f00 100644 --- a/src/ui/components/StatusModal.tsx +++ b/src/ui/components/StatusModal.tsx @@ -15,6 +15,7 @@ export const StatusModal = ({ onReply, onToggleFavourite, onToggleReblog, + onToggleBookmark, onDelete, onProfileClick, activeHandle, @@ -33,6 +34,7 @@ export const StatusModal = ({ onReply: (status: Status) => void; onToggleFavourite: (status: Status) => void; onToggleReblog: (status: Status) => void; + onToggleBookmark: (status: Status) => void; onDelete?: (status: Status) => void; onProfileClick?: (status: Status, account: Account | null) => void; activeHandle: string; @@ -194,12 +196,13 @@ export const StatusModal = ({
{threadContext.ancestors.map((ancestorStatus) => (
- {})} + {})} onProfileClick={handleProfileClick} activeHandle={activeHandle} activeAccountHandle={activeAccountHandle} @@ -229,6 +232,7 @@ export const StatusModal = ({ onReply={onReply} onToggleFavourite={onToggleFavourite} onToggleReblog={onToggleReblog} + onToggleBookmark={onToggleBookmark} onDelete={onDelete || (() => {})} onProfileClick={handleProfileClick} activeHandle={activeHandle} @@ -258,6 +262,7 @@ export const StatusModal = ({ onReply={onReply} onToggleFavourite={onToggleFavourite} onToggleReblog={onToggleReblog} + onToggleBookmark={onToggleBookmark} onDelete={onDelete || (() => {})} onProfileClick={handleProfileClick} activeHandle={activeHandle} diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index f21915f..773de1d 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { Account, CustomEmoji, Mention, ReactionInput, Status } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; import { sanitizeHtml } from "../utils/htmlSanitizer"; -import { renderTextWithLinks } from "../utils/linkify"; +import { renderTextWithLinks, type MentionLink } from "../utils/linkify"; import BoostIcon from "../assets/boost-icon.svg?react"; import ReplyIcon from "../assets/reply-icon.svg?react"; import TrashIcon from "../assets/trash-icon.svg?react"; @@ -30,6 +30,7 @@ export const TimelineItem = ({ onToggleFavourite, onToggleReblog, onDelete, + onToggleBookmark, onReact, onProfileClick, onStatusClick, @@ -49,6 +50,7 @@ export const TimelineItem = ({ onToggleFavourite: (status: Status) => void; onToggleReblog: (status: Status) => void; onDelete: (status: Status) => void; + onToggleBookmark: (status: Status) => void; onReact?: (status: Status, reaction: ReactionInput) => void; onProfileClick?: (status: Status) => void; onStatusClick?: (status: Status) => void; @@ -473,6 +475,7 @@ export const TimelineItem = ({ reactions: [], reblogged: false, favourited: false, + bookmarked: false, inReplyToId: null, mentions: [], mediaAttachments: [], @@ -486,11 +489,11 @@ export const TimelineItem = ({ [displayStatus.createdAt, displayStatus.id] ); const handleMentionClick = useCallback( - (mention: Mention) => { + (mention: MentionLink) => { if (!onProfileClick || !mention.id) { return; } - onProfileClick(buildMentionStatus(mention)); + onProfileClick(buildMentionStatus(mention as Mention)); }, [buildMentionStatus, onProfileClick] ); @@ -861,6 +864,15 @@ export const TimelineItem = ({ <>