diff --git a/AGENTS.md b/AGENTS.md index ed7145c..993c6b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# AGENTS.md +# AGENTS.md ## 기본 원칙 - SOLID 원칙을 준수한다. @@ -17,6 +17,7 @@ - 모든 소스 파일은 UTF-8 (BOM 없음)으로 저장한다. - 커밋 전 텍스트 깨짐 검사: `rg -n "�" src`, `rg -n "[\u00C0-\u00FF]" src` - UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다. +- 분석을 위하여 파일을 읽을 때는 CJK 문자가 깨지지 않도록 해야 한다. ## 작업 플로우 - 작업 시작 전: `develop` 최신화 → 새 feature 브랜치 생성. @@ -30,3 +31,5 @@ - PR 본문은 마크다운이 깨지지 않도록 멀티라인(heredoc) 방식으로 작성한다. - PR 제목/본문의 한글 인코딩이 깨지지 않도록 UTF-8로 작성·확인한다. - PR URL은 코드 블록 없이 클릭 가능한 일반 텍스트로 제공한다. +- PR 생성 시 feature/* 브랜치는 반드시 develop 브랜치를 베이스로 삼는다. +- PR 생성 시 release/* 브랜치는 반드시 main 브랜치를 베이스로 삼는다. diff --git a/CLAUDE.MD b/CLAUDE.MD index 7e27b1b..e57c56b 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -25,8 +25,11 @@ - PR 본문은 마크다운이 깨지지 않도록 멀티라인(heredoc) 방식으로 작성한다. - PR 제목/본문의 한글 인코딩이 깨지지 않도록 UTF-8로 작성·확인한다. - PR URL은 코드 블록 없이 클릭 가능한 일반 텍스트로 제공한다. +- PR 생성 시 feature/* 브랜치는 반드시 develop 브랜치를 베이스로 삼는다. +- PR 생성 시 release/* 브랜치는 반드시 main 브랜치를 베이스로 삼는다. ## 인코딩/텍스트 품질 - 모든 소스 파일은 UTF-8 (BOM 없음)으로 저장한다. - 커밋 전 텍스트 깨짐 검사: `rg -n "�" src`, `rg -n "[\u00C0-\u00FF]" src` -- UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다. \ No newline at end of file +- UI 문자열에 한글 외의 CJK 문자가 섞여 있으면 원인을 확인하고 교체한다. +- 분석을 위하여 파일을 읽을 때는 CJK 문자가 깨지지 않도록 해야 한다. \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index fa7b957..9f6ff0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,11 +6,12 @@ import { ComposeBox } from "./ui/components/ComposeBox"; import { ProfileModal } from "./ui/components/ProfileModal"; import { StatusModal } from "./ui/components/StatusModal"; import { TimelineItem } from "./ui/components/TimelineItem"; +import { PomodoroTimer } from "./ui/components/PomodoroTimer"; import { useTimeline } from "./ui/hooks/useTimeline"; import { useClickOutside } from "./ui/hooks/useClickOutside"; import { useAppContext } from "./ui/state/AppContext"; import type { AccountsState, AppServices } from "./ui/state/AppContext"; -import { createAccountId, formatHandle, normalizeInstanceUrl } from "./ui/utils/account"; +import { createAccountId, formatHandle, formatReplyHandle, normalizeInstanceUrl } from "./ui/utils/account"; import { clearPendingOAuth, createOauthState, loadPendingOAuth, loadRegisteredApp, saveRegisteredApp, storePendingOAuth } from "./ui/utils/oauth"; import { getTimelineLabel, getTimelineOptions, normalizeTimelineType } from "./ui/utils/timeline"; import { sanitizeHtml } from "./ui/utils/htmlSanitizer"; @@ -132,8 +133,10 @@ const buildOptimisticReactionStatus = ( }; }; -const TimelineIcon = ({ timeline }: { timeline: TimelineType }) => { +const TimelineIcon = ({ timeline }: { timeline: TimelineType | string }) => { switch (timeline) { + case "divider-before-bookmarks": + return null; case "home": return ( ); + case "bookmarks": + return ( + + ); default: return null; } @@ -400,8 +409,12 @@ const TimelineSection = ({ timelineType, onNotification: handleNotification }); - const actionsDisabled = timelineType === "notifications"; - const emptyMessage = timelineType === "notifications" ? "표시할 알림이 없습니다." : "표시할 글이 없습니다."; + const actionsDisabled = timelineType === "notifications" || timelineType === "bookmarks"; + const emptyMessage = timelineType === "notifications" + ? "표시할 알림이 없습니다." + : timelineType === "bookmarks" + ? "북마크한 글이 없습니다." + : "표시할 글이 없습니다."; useEffect(() => { if (!timeline.error) { @@ -529,6 +542,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); @@ -609,7 +650,13 @@ const TimelineSection = ({ aria-label="타임라인 선택" > {timelineOptions.map((option) => { - const isSelected = timelineType === option.id; + if (option.isDivider) { + return ( +
+ ); + } + + const isSelected = !option.isDivider && timelineType === option.id; return ( ); @@ -678,8 +728,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 +861,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={ @@ -897,6 +949,23 @@ export const App = () => { const [showMisskeyReactions, setShowMisskeyReactions] = useState(() => { return localStorage.getItem("textodon.reactions") !== "off"; }); + const [showPomodoro, setShowPomodoro] = useState(() => { + return localStorage.getItem("textodon.pomodoro") === "on"; + }); + const [pomodoroFocus, setPomodoroFocus] = useState(() => { + const stored = localStorage.getItem("textodon.pomodoro.focus"); + return stored ? Number(stored) : 25; + }); + const [pomodoroBreak, setPomodoroBreak] = useState(() => { + const stored = localStorage.getItem("textodon.pomodoro.break"); + return stored ? Number(stored) : 5; + }); + const [pomodoroLongBreak, setPomodoroLongBreak] = useState(() => { + const stored = localStorage.getItem("textodon.pomodoro.longBreak"); + return stored ? Number(stored) : 30; + }); + const [pomodoroSessionType, setPomodoroSessionType] = useState<"focus" | "break" | "longBreak">("focus"); + const [pomodoroIsRunning, setPomodoroIsRunning] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsAccountId, setSettingsAccountId] = useState(null); const [reauthLoading, setReauthLoading] = useState(false); @@ -966,7 +1035,7 @@ export const App = () => { const dragStateRef = useRef<{ startX: number; scrollLeft: number; pointerId: number } | null>(null); const [isBoardDragging, setIsBoardDragging] = useState(false); const replySummary = replyTarget - ? `@${replyTarget.accountHandle} · ${replyTarget.content.slice(0, 80)}` + ? `@${formatReplyHandle(replyTarget.accountHandle, replyTarget.accountUrl, composeAccount?.instanceUrl ?? "")} · ${replyTarget.content.slice(0, 80)}` : null; const [route, setRoute] = useState(() => parseRoute()); const timelineListeners = useRef void>>>(new Map()); @@ -1195,6 +1264,22 @@ export const App = () => { localStorage.setItem("textodon.reactions", showMisskeyReactions ? "on" : "off"); }, [showMisskeyReactions]); + useEffect(() => { + localStorage.setItem("textodon.pomodoro", showPomodoro ? "on" : "off"); + }, [showPomodoro]); + + useEffect(() => { + localStorage.setItem("textodon.pomodoro.focus", String(pomodoroFocus)); + }, [pomodoroFocus]); + + useEffect(() => { + localStorage.setItem("textodon.pomodoro.break", String(pomodoroBreak)); + }, [pomodoroBreak]); + + useEffect(() => { + localStorage.setItem("textodon.pomodoro.longBreak", String(pomodoroLongBreak)); + }, [pomodoroLongBreak]); + const closeMobileMenu = useCallback(() => { setMobileMenuOpen(false); setMobileComposeOpen(false); @@ -1408,7 +1493,8 @@ export const App = () => { } setComposeAccountId(account.id); setReplyTarget(status); - setMentionSeed(`@${status.accountHandle}`); + const formattedHandle = formatReplyHandle(status.accountHandle, status.accountUrl, account.instanceUrl); + setMentionSeed(`@${formattedHandle}`); setSelectedStatus(null); }; @@ -1622,6 +1708,15 @@ export const App = () => { /> ) : null}
+ {route === "home" && showPomodoro ? ( + + ) : null} {route === "home" ? (
@@ -1696,7 +1791,16 @@ export const App = () => { {oauthLoading ?

OAuth 인증 중...

: null} {route === "home" ? (
- {sections.length > 0 ? ( + {showPomodoro && pomodoroSessionType === "focus" && pomodoroIsRunning ? ( +
+
+

🎯 집중 세션 진행 중

+

뽀모도로 타이머가 동작 중입니다.
타임라인은 집중이 끝날 때까지 숨겨집니다.

+
+
+ ) : null} +
+ {sections.length > 0 ? (
) : null} +
) : null} {route === "terms" ? : null} @@ -1986,6 +2091,60 @@ onAccountChange={setSectionAccount}
+
+
+ 뽀모도로 타이머 +

사이드바에 뽀모도로 타이머를 표시합니다.

+
+ +
+ {showPomodoro ? ( +
+
+ 뽀모도로 시간 설정 +

집중, 휴식, 긴 휴식 시간을 분 단위로 설정합니다.

+
+
+ + + +
+
+ ) : null}
로컬 저장소 초기화 @@ -2067,6 +2226,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..322acd6 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -2,7 +2,7 @@ export type Visibility = "public" | "unlisted" | "private" | "direct"; export type AccountPlatform = "mastodon" | "misskey"; -export type TimelineType = "home" | "local" | "federated" | "social" | "global" | "notifications"; +export type TimelineType = "home" | "local" | "federated" | "social" | "global" | "notifications" | "bookmarks"; export type Account = { id: string; @@ -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..4072968 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -110,6 +110,22 @@ export class MastodonHttpClient implements MastodonApi { return data.map(mapStatus); } + async fetchBookmarks(account: Account, limit: number = 20, maxId?: string): Promise { + const url = new URL(`${account.instanceUrl}/api/v1/bookmarks`); + url.searchParams.set("limit", String(limit)); + if (maxId) { + url.searchParams.set("max_id", maxId); + } + const response = await fetch(url.toString(), { + headers: buildHeaders(account) + }); + if (!response.ok) { + throw new Error("북마크를 불러오지 못했습니다."); + } + const data = (await response.json()) as unknown[]; + return data.map(mapStatus); + } + async fetchCustomEmojis(account: Account): Promise { const response = await fetch(`${account.instanceUrl}/api/v1/custom_emojis`, { headers: buildHeaders(account) @@ -283,6 +299,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 +406,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..cc7aa29 100644 --- a/src/infra/UnifiedApiClient.ts +++ b/src/infra/UnifiedApiClient.ts @@ -75,6 +75,10 @@ export class UnifiedApiClient implements MastodonApi { return this.getClient(account).fetchAccountStatuses(account, accountId, limit, maxId); } + fetchBookmarks(account: Account, limit?: number, maxId?: string) { + return this.getClient(account).fetchBookmarks(account, limit, maxId); + } + uploadMedia(account: Account, file: File) { return this.getClient(account).uploadMedia(account, file); } @@ -95,6 +99,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..2f63add 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,9 @@ 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; + fetchBookmarks(account: Account, limit?: number, maxId?: string): Promise; createReaction(account: Account, statusId: string, reaction: string): Promise; deleteReaction(account: Account, statusId: string): Promise; reblog(account: Account, statusId: string): Promise; @@ -36,4 +39,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/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx new file mode 100644 index 0000000..e35b090 --- /dev/null +++ b/src/ui/components/PomodoroTimer.tsx @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type SessionType = "focus" | "break" | "longBreak"; + +type PomodoroTimerProps = { + focusMinutes?: number; + breakMinutes?: number; + longBreakMinutes?: number; + onSessionTypeChange?: (type: SessionType) => void; + onRunningChange?: (isRunning: boolean) => void; +}; + +const TOTAL_SESSIONS = 8; + +const getSessionLabel = (type: SessionType): string => { + switch (type) { + case "focus": + return "집중"; + case "break": + return "휴식"; + case "longBreak": + return "긴 휴식"; + } +}; + +const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; +}; + +export const PomodoroTimer = ({ + focusMinutes = 25, + breakMinutes = 5, + longBreakMinutes = 30, + onSessionTypeChange, + onRunningChange, +}: PomodoroTimerProps) => { + const focusDuration = focusMinutes * 60; + const breakDuration = breakMinutes * 60; + const longBreakDuration = longBreakMinutes * 60; + + const getSessionInfo = useCallback( + (sess: number): { type: SessionType; duration: number } => { + if (sess === 8) { + return { type: "longBreak", duration: longBreakDuration }; + } + if (sess % 2 === 0) { + return { type: "break", duration: breakDuration }; + } + return { type: "focus", duration: focusDuration }; + }, + [focusDuration, breakDuration, longBreakDuration] + ); + + const [session, setSession] = useState(1); + const [timeLeft, setTimeLeft] = useState(focusDuration); + const [isRunning, setIsRunning] = useState(false); + const [isBlinking, setIsBlinking] = useState(false); + const intervalRef = useRef(null); + const audioContextRef = useRef(null); + + const sessionInfo = useMemo(() => getSessionInfo(session), [session, getSessionInfo]); + + // 세션 타입 변경 시 부모 컴포넌트에 알림 + useEffect(() => { + onSessionTypeChange?.(sessionInfo.type); + }, [sessionInfo.type, onSessionTypeChange]); + + // 실행 상태 변경 시 부모 컴포넌트에 알림 + useEffect(() => { + onRunningChange?.(isRunning); + }, [isRunning, onRunningChange]); + + const playNotificationSound = useCallback(() => { + try { + if (!audioContextRef.current) { + audioContextRef.current = new AudioContext(); + } + const ctx = audioContextRef.current; + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.frequency.value = 800; + oscillator.type = "sine"; + gainNode.gain.setValueAtTime(0.3, ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5); + + oscillator.start(ctx.currentTime); + oscillator.stop(ctx.currentTime + 0.5); + } catch { + // AudioContext가 지원되지 않는 환경에서는 무시 + } + }, []); + + const handleSessionToggle = useCallback(() => { + const nextSession = session >= TOTAL_SESSIONS ? 1 : session + 1; + const nextInfo = getSessionInfo(nextSession); + setSession(nextSession); + setTimeLeft(nextInfo.duration); + setIsRunning(false); + setIsBlinking(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, [session, getSessionInfo]); + + const handleStart = useCallback(() => { + setIsBlinking(false); + setIsRunning((prev) => !prev); + }, []); + + const handleReset = useCallback(() => { + setIsBlinking(false); + setSession(1); + setTimeLeft(focusDuration); + setIsRunning(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, [focusDuration]); + + // 설정이 변경되면 현재 세션 시간 업데이트 + useEffect(() => { + const info = getSessionInfo(session); + setTimeLeft(info.duration); + setIsRunning(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, [focusDuration, breakDuration, longBreakDuration]); + + useEffect(() => { + if (isRunning) { + intervalRef.current = window.setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + playNotificationSound(); + setIsBlinking(true); + const nextSession = session >= TOTAL_SESSIONS ? 1 : session + 1; + const nextInfo = getSessionInfo(nextSession); + setSession(nextSession); + setIsRunning(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return nextInfo.duration; + } + return prev - 1; + }); + }, 1000); + } else if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [isRunning, session, playNotificationSound]); + + const focusCount = Math.ceil(session / 2); + + const handlePanelClick = useCallback(() => { + if (isBlinking) { + setIsBlinking(false); + } + }, [isBlinking]); + + return ( +
+
+ + + {formatTime(timeLeft)} + +
+ + +
+
+
+ ); +}; 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 = ({ <>