diff --git a/src/App.tsx b/src/App.tsx index 9f6ff0c..2cfcfda 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -560,9 +560,9 @@ const TimelineSection = ({ : await services.api.bookmark(account, status.id); timeline.updateItem(updated); if (isBookmarking) { - showToast("북마크했습니다."); + showToast("북마크했습니다.", { tone: "success" }); } else { - showToast("북마크를 취소했습니다."); + showToast("북마크를 취소했습니다.", { tone: "success" }); } } catch (err) { onError(err instanceof Error ? err.message : "북마크 처리에 실패했습니다."); diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index 4072968..9280c78 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -398,12 +398,12 @@ export class MastodonHttpClient implements MastodonApi { } } - async favourite(account: Account, statusId: string): Promise { - return this.postAction(account, statusId, "favourite"); + async favourite(_account: Account, _statusId: string): Promise { + throw new Error("즐겨찾기는 미스키 계정에서만 사용할 수 있습니다."); } - async unfavourite(account: Account, statusId: string): Promise { - return this.postAction(account, statusId, "unfavourite"); + async unfavourite(_account: Account, _statusId: string): Promise { + throw new Error("즐겨찾기는 미스키 계정에서만 사용할 수 있습니다."); } async bookmark(account: Account, statusId: string): Promise { @@ -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..0a12121 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,41 @@ 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 { + throw new Error("북마크는 마스토돈 계정에서만 사용할 수 있습니다."); + } + + async unbookmark(_account: Account, _statusId: string): Promise { + throw new Error("북마크는 마스토돈 계정에서만 사용할 수 있습니다."); + } + + async fetchBookmarks(_account: Account, _limit?: number, _maxId?: string): Promise { + throw new Error("북마크는 마스토돈 계정에서만 사용할 수 있습니다."); + } + + 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/ProfileModal.tsx b/src/ui/components/ProfileModal.tsx index f65c606..8106315 100644 --- a/src/ui/components/ProfileModal.tsx +++ b/src/ui/components/ProfileModal.tsx @@ -378,7 +378,7 @@ export const ProfileModal = ({ ? await api.unbookmark(account, target.id) : await api.bookmark(account, target.id); updateItem(updated); - showToast(isBookmarking ? "북마크했습니다." : "북마크를 취소했습니다."); + showToast(isBookmarking ? "북마크했습니다." : "북마크를 취소했습니다.", { tone: "success" }); } catch (error) { setItemsError(error instanceof Error ? error.message : "북마크 처리에 실패했습니다."); } diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index 773de1d..3f0fcb0 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,31 @@ 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(false); 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 && account.platform === "misskey") { + // 초기 상태를 null로 설정하여 비활성화 상태로 표시 + setFavouriteState(null); + + try { + const state = await api.fetchNoteState(account, displayStatus.id); + setFavouriteState(state.isFavourited); + } catch (error) { + console.error("즐겨찾기 상태 확인 실패:", error); + setFavouriteState(false); // 실패 시 기본값은 false로 설정 + } + } + }, [menuOpen, account, api, displayStatus.id]); // useImageZoom 사용 const { @@ -852,7 +874,7 @@ export const TimelineItem = ({ className="icon-button" aria-label="게시글 메뉴 열기" aria-haspopup="menu" aria-expanded={menuOpen} - onClick={() => setMenuOpen((current) => !current)} + onClick={handleMenuToggle} >