From 46b90854e8975d56ad816f7df759eb06b15ab343 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 18 Jan 2026 20:28:54 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EB=AF=B8=EC=8A=A4=ED=82=A4=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EB=AC=BC=20=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 주요 변경사항 ### API 레이어 - `fetchNoteState` API 메소드 추가 (MastodonHttpClient, MisskeyHttpClient, UnifiedApiClient) - `MastodonApi` 인터페이스에 `fetchNoteState` 선언 추가 - MisskeyHttpClient의 즐겨찾기/취소 로직 수정 (리액션 → 순수 즐겨찾기 API) - 누락된 북마크 관련 메소드 구현 ### UI 기능 - TimelineItem 더보기 메뉴에 즐겨찾기/취소 버튼 추가 - 메뉴 열 때 notes/state API로 즐겨찾기 상태 실시간 확인 - 즐겨찾기 성공/실패 시 토스트 알림 표시 - 상태별 적절한 API 호출 (create/delete) ### 기능 개선 - 즐겨찾기 상태 로컬 관리로 불필요한 API 호출 방지 - 마스토돈/미스키 통합 API 동작 - 정확한 즐겨찾기 취소 API 호출 (delete 사용) ### 수정된 이슈 - 즐겨찾기 시 좋아요(리액션)이 아닌 즐겨찾기로 동작 - 즐겨찾기 취소 시 delete API 사용 - API 호출 실패 시 에러 처리 및 토스트 알림 --- src/infra/MastodonHttpClient.ts | 19 ++++++++ src/infra/MisskeyHttpClient.ts | 70 ++++++++++++++++++++++++------ src/infra/UnifiedApiClient.ts | 4 ++ src/services/MastodonApi.ts | 1 + src/ui/components/TimelineItem.tsx | 53 +++++++++++++++++++++- 5 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index 4072968..02bf5c8 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -422,6 +422,25 @@ export class MastodonHttpClient implements MastodonApi { throw new Error("리액션은 미스키 계정에서만 사용할 수 있습니다."); } + async fetchNoteState(account: Account, noteId: string): Promise<{ isFavourited: boolean; isReblogged: boolean; bookmarked: boolean }> { + // 마스토돈은 이미 Status 객체에 즐겨찾기, 리블로그, 북마크 상태가 포함되어 있음 + const response = await fetch(`${account.instanceUrl}/api/v1/statuses/${noteId}`, { + headers: { + "Authorization": `Bearer ${account.accessToken}` + } + }); + if (!response.ok) { + throw new Error("게시물 상태를 불러오지 못했습니다."); + } + const data = (await response.json()) as unknown; + const status = mapStatus(data); + return { + isFavourited: status.favourited, + isReblogged: status.reblogged, + bookmarked: status.bookmarked + }; + } + async reblog(account: Account, statusId: string): Promise { return this.postAction(account, statusId, "reblog"); } diff --git a/src/infra/MisskeyHttpClient.ts b/src/infra/MisskeyHttpClient.ts index 9230568..c88a94d 100644 --- a/src/infra/MisskeyHttpClient.ts +++ b/src/infra/MisskeyHttpClient.ts @@ -421,23 +421,12 @@ export class MisskeyHttpClient implements MastodonApi { } async favourite(account: Account, statusId: string): Promise { - try { - await this.postSimple(account, "/api/notes/reactions/create", { - noteId: statusId, - reaction: DEFAULT_REACTION - }); - } catch { - await this.postSimple(account, "/api/notes/favorites/create", { noteId: statusId }); - } + await this.postSimple(account, "/api/notes/favorites/create", { noteId: statusId }); return this.fetchNote(account, statusId); } async unfavourite(account: Account, statusId: string): Promise { - try { - await this.postSimple(account, "/api/notes/reactions/delete", { noteId: statusId }); - } catch { - await this.postSimple(account, "/api/notes/favorites/delete", { noteId: statusId }); - } + await this.postSimple(account, "/api/notes/favorites/delete", { noteId: statusId }); return this.fetchNote(account, statusId); } @@ -526,6 +515,61 @@ export class MisskeyHttpClient implements MastodonApi { return data.map((item) => mapMisskeyStatusWithInstance(item, account.instanceUrl)); } + async fetchNoteState(account: Account, noteId: string): Promise<{ isFavourited: boolean; isReblogged: boolean; bookmarked: boolean }> { + const response = await fetch(`${normalizeInstanceUrl(account.instanceUrl)}/api/notes/state`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(buildBody(account, { noteId })) + }); + if (!response.ok) { + throw new Error("게시물 상태를 불러오지 못했습니다."); + } + const data = (await response.json()) as Record; + return { + isFavourited: Boolean(data.isFavorited ?? data.isFavourited ?? false), + isReblogged: Boolean(data.isRenoted ?? false), + bookmarked: Boolean(data.bookmarked ?? false) + }; + } + + async bookmark(account: Account, statusId: string): Promise { + await this.postSimple(account, "/api/notes/favorites/create", { noteId: statusId }); + return this.fetchNote(account, statusId); + } + + async unbookmark(account: Account, statusId: string): Promise { + await this.postSimple(account, "/api/notes/favorites/delete", { noteId: statusId }); + return this.fetchNote(account, statusId); + } + + async fetchBookmarks(account: Account, limit?: number, maxId?: string): Promise { + const response = await fetch(`${normalizeInstanceUrl(account.instanceUrl)}/api/i/favorites`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(buildBody(account, { + limit: limit ?? 20, + untilId: maxId + })) + }); + if (!response.ok) { + throw new Error("북마크를 불러오지 못했습니다."); + } + const data = (await response.json()) as unknown[]; + return data.map((item) => { + const typed = item as Record; + const note = typed.note as unknown; + return mapMisskeyStatusWithInstance(note, account.instanceUrl); + }).filter((status): status is Status => status !== null); + } + + async fetchThreadContext(account: Account, statusId: string): Promise { + return this.fetchConversation(account, statusId); + } + private async fetchNotifications( account: Account, limit: number, diff --git a/src/infra/UnifiedApiClient.ts b/src/infra/UnifiedApiClient.ts index cc7aa29..03558a0 100644 --- a/src/infra/UnifiedApiClient.ts +++ b/src/infra/UnifiedApiClient.ts @@ -123,6 +123,10 @@ export class UnifiedApiClient implements MastodonApi { return this.getClient(account).unreblog(account, statusId); } + fetchNoteState(account: Account, noteId: string) { + return this.getClient(account).fetchNoteState(account, noteId); + } + async fetchThreadContext(account: Account, statusId: string): Promise { if (account.platform === "misskey") { // MisskeyHttpClient에는 fetchConversation 메서드가 있음 diff --git a/src/services/MastodonApi.ts b/src/services/MastodonApi.ts index 2f63add..f09c5d8 100644 --- a/src/services/MastodonApi.ts +++ b/src/services/MastodonApi.ts @@ -40,4 +40,5 @@ export interface MastodonApi { unblockAccount(account: Account, accountId: string): Promise; fetchAccountStatuses(account: Account, accountId: string, limit: number, maxId?: string): Promise; fetchThreadContext(account: Account, statusId: string): Promise; + fetchNoteState(account: Account, noteId: string): Promise<{ isFavourited: boolean; isReblogged: boolean; bookmarked: boolean }>; } diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index 773de1d..256e335 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -10,6 +10,7 @@ import { ReactionPicker } from "./ReactionPicker"; import { useClickOutside } from "../hooks/useClickOutside"; import { useImageZoom } from "../hooks/useImageZoom"; import { AccountLabel } from "./AccountLabel"; +import { useToast } from "../state/ToastContext"; const normalizeMentionHandle = (handle: string): string => handle.replace(/^@/, "").trim().toLowerCase(); @@ -71,10 +72,27 @@ export const TimelineItem = ({ const [activeImageIndex, setActiveImageIndex] = useState(null); const [showContent, setShowContent] = useState(() => displayStatus.spoilerText.length === 0); const [menuOpen, setMenuOpen] = useState(false); + const [favouriteState, setFavouriteState] = useState(null); const imageContainerRef = useRef(null); const imageRef = useRef(null); const menuRef = useRef(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const { showToast } = useToast(); + + // 메뉴 열 때 즐겨찾기 상태 확인 + const handleMenuToggle = useCallback(async () => { + const willOpen = !menuOpen; + setMenuOpen(willOpen); + + if (willOpen && account && api) { + try { + const state = await api.fetchNoteState(account, displayStatus.id); + setFavouriteState(state.isFavourited); + } catch (error) { + console.error("즐겨찾기 상태 확인 실패:", error); + } + } + }, [menuOpen, account, api, displayStatus.id]); // useImageZoom 사용 const { @@ -852,7 +870,7 @@ export const TimelineItem = ({ className="icon-button" aria-label="게시글 메뉴 열기" aria-haspopup="menu" aria-expanded={menuOpen} - onClick={() => setMenuOpen((current) => !current)} + onClick={handleMenuToggle} >