diff --git a/src/App.tsx b/src/App.tsx index 9f6ff0c..ec3ba50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,30 +1,28 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Account, Reaction, ReactionInput, Status, TimelineType } from "./domain/types"; +import type { Account, ReactionInput, Status, TimelineType } from "./domain/types"; import { AccountAdd } from "./ui/components/AccountAdd"; import { AccountSelector } from "./ui/components/AccountSelector"; import { ComposeBox } from "./ui/components/ComposeBox"; +import { InfoModal } from "./ui/components/InfoModal"; +import { MobileComposeMenu, MobileMenu } from "./ui/components/MobileMenus"; +import { PomodoroTimer } from "./ui/components/PomodoroTimer"; import { ProfileModal } from "./ui/components/ProfileModal"; +import { SettingsModal } from "./ui/components/SettingsModal"; 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 { TimelineSection, type TimelineSectionConfig } from "./ui/components/TimelineSection"; +import { LicensePage, OssPage, ShortcutsPage, TermsPage } from "./ui/pages/InfoPages"; import { useAppContext } from "./ui/state/AppContext"; -import type { AccountsState, AppServices } from "./ui/state/AppContext"; +import { useToast } from "./ui/state/ToastContext"; 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"; -import { renderMarkdown } from "./ui/utils/markdown"; -import { useToast } from "./ui/state/ToastContext"; +import { normalizeTimelineType } from "./ui/utils/timeline"; +import { buildOptimisticReactionStatus, hasSameReactions } from "./ui/utils/reactions"; +import { ColorScheme, ThemeMode, getStoredColorScheme, getStoredTheme, isColorScheme, isThemeMode } from "./ui/utils/theme"; +import type { InfoModalType } from "./ui/types/info"; import logoUrl from "./ui/assets/textodon-icon-blue.png"; -import licenseText from "../LICENSE?raw"; -import ossMarkdown from "./ui/content/oss.md?raw"; -import termsMarkdown from "./ui/content/terms.md?raw"; -type Route = "home" | "terms" | "license" | "oss"; -type InfoModalType = "terms" | "license" | "oss"; -type TimelineSectionConfig = { id: string; accountId: string | null; timelineType: TimelineType }; +type Route = "home" | "terms" | "license" | "oss" | "shortcuts"; +type SelectedTimelineStatus = { sectionId: string; statusId: string }; type ProfileTarget = { status: Status; account: Account | null; zIndex: number }; const SECTION_STORAGE_KEY = "textodon.sections"; @@ -39,897 +37,10 @@ const parseRoute = (): Route => { if (path === "terms") return "terms"; if (path === "license") return "license"; if (path === "oss") return "oss"; + if (path === "shortcuts") return "shortcuts"; return "home"; }; -const PageHeader = ({ title }: { title: string }) => ( -
- - - 타임라인으로 돌아가기 - -

{title}

-
-); - -const sortReactions = (reactions: Reaction[]) => - [...reactions].sort((a, b) => { - if (a.count === b.count) { - return a.name.localeCompare(b.name); - } - return b.count - a.count; - }); - -const buildReactionSignature = (reactions: Reaction[]) => - sortReactions(reactions).map((reaction) => - [reaction.name, reaction.count, reaction.url ?? "", reaction.isCustom ? "1" : "0", reaction.host ?? ""].join("|") - ); - -const hasSameReactions = (left: Status, right: Status) => { - if (left.myReaction !== right.myReaction) { - return false; - } - const leftSig = buildReactionSignature(left.reactions); - const rightSig = buildReactionSignature(right.reactions); - if (leftSig.length !== rightSig.length) { - return false; - } - return leftSig.every((value, index) => value === rightSig[index]); -}; - -const adjustReactionCount = ( - reactions: Reaction[], - name: string, - delta: number, - fallback?: ReactionInput -) => { - let updated = false; - const next = reactions - .map((reaction) => { - if (reaction.name !== name) { - return reaction; - } - updated = true; - const count = reaction.count + delta; - if (count <= 0) { - return null; - } - return { ...reaction, count }; - }) - .filter((reaction): reaction is Reaction => reaction !== null); - - if (!updated && delta > 0 && fallback) { - next.push({ ...fallback, count: delta }); - } - - return next; -}; - -const buildOptimisticReactionStatus = ( - status: Status, - reaction: ReactionInput, - remove: boolean -): Status => { - let nextReactions = status.reactions; - if (remove) { - nextReactions = adjustReactionCount(nextReactions, reaction.name, -1); - } else { - if (status.myReaction && status.myReaction !== reaction.name) { - nextReactions = adjustReactionCount(nextReactions, status.myReaction, -1); - } - nextReactions = adjustReactionCount(nextReactions, reaction.name, 1, reaction); - } - const sorted = sortReactions(nextReactions); - const favouritesCount = sorted.reduce((sum, item) => sum + item.count, 0); - const myReaction = remove ? null : reaction.name; - return { - ...status, - reactions: sorted, - myReaction, - favouritesCount, - favourited: Boolean(myReaction) - }; -}; - -const TimelineIcon = ({ timeline }: { timeline: TimelineType | string }) => { - switch (timeline) { - case "divider-before-bookmarks": - return null; - case "home": - return ( - - ); - case "local": - return ( - - ); - case "federated": - return ( - - ); - case "social": - return ( - - ); - case "global": - return ( - - ); - case "notifications": - return ( - - ); - case "bookmarks": - return ( - - ); - default: - return null; - } -}; - -const termsHtml = sanitizeHtml(renderMarkdown(termsMarkdown)); -const ossHtml = sanitizeHtml(renderMarkdown(ossMarkdown)); - -const TermsContent = () => ( -
-); - -const LicenseContent = () =>
{licenseText}
; - -const OssContent = () => ( -
-); - -const getInfoModalTitle = (type: InfoModalType) => { - switch (type) { - case "terms": - return "이용약관"; - case "license": - return "라이선스"; - case "oss": - return "오픈소스 목록"; - default: - return ""; - } -}; - -const InfoModalContent = ({ type }: { type: InfoModalType }) => { - switch (type) { - case "terms": - return ; - case "license": - return ; - case "oss": - return ; - default: - return null; - } -}; - -const InfoModal = ({ type, onClose }: { type: InfoModalType; onClose: () => void }) => { - const title = getInfoModalTitle(type); - return ( -
-
-
-
-

{title}

- -
-
- -
-
-
- ); -}; - -const TermsPage = () => ( -
- - -
-); - -const LicensePage = () => ( -
- - -
-); - -const OssPage = () => ( -
- - -
-); - -const TimelineSection = ({ - section, - account, - services, - accountsState, - onAccountChange, - onTimelineChange, - onAddSectionLeft, - onAddSectionRight, - onRemoveSection, - onReply, - onStatusClick, - onCloseStatusModal, - onReact, - onProfileClick, - onError, - onMoveSection, - onScrollToSection, - canMoveLeft, - canMoveRight, - canRemoveSection, - timelineType, - showProfileImage, - showCustomEmojis, - showReactions, - registerTimelineListener, - unregisterTimelineListener, - columnRef -}: { - section: TimelineSectionConfig; - account: Account | null; - services: AppServices; - accountsState: AccountsState; - onAccountChange: (sectionId: string, accountId: string | null) => void; - onTimelineChange: (sectionId: string, timelineType: TimelineType) => void; - onAddSectionLeft: (sectionId: string) => void; - onAddSectionRight: (sectionId: string) => void; - onRemoveSection: (sectionId: string) => void; - onReply: (status: Status, account: Account | null) => void; - onStatusClick: (status: Status, columnAccount: Account | null) => void; - onReact: (account: Account | null, status: Status, reaction: ReactionInput) => void; - onProfileClick: (status: Status, account: Account | null) => void; - onError: (message: string | null) => void; - columnAccount: Account | null; - onMoveSection: (sectionId: string, direction: "left" | "right") => void; - onScrollToSection: (sectionId: string) => void; - onCloseStatusModal: () => void; - canMoveLeft: boolean; - canMoveRight: boolean; - canRemoveSection: boolean; - timelineType: TimelineType; - showProfileImage: boolean; - showCustomEmojis: boolean; - showReactions: boolean; - registerTimelineListener: (accountId: string, listener: (status: Status) => void) => void; - unregisterTimelineListener: (accountId: string, listener: (status: Status) => void) => void; - columnRef?: React.Ref; -}) => { - const notificationsTimeline = useTimeline({ - account, - api: services.api, - streaming: services.streaming, - timelineType: "notifications", - enableStreaming: false - }); - const { - items: notificationItems, - loading: notificationsLoading, - loadingMore: notificationsLoadingMore, - error: notificationsError, - refresh: refreshNotifications, - loadMore: loadMoreNotifications - } = notificationsTimeline; - const menuRef = useRef(null); - const timelineMenuRef = useRef(null); - const notificationMenuRef = useRef(null); - const scrollRef = useRef(null); - const notificationScrollRef = useRef(null); - const lastNotificationToastRef = useRef(0); - const [menuOpen, setMenuOpen] = useState(false); - const [timelineMenuOpen, setTimelineMenuOpen] = useState(false); - const [notificationsOpen, setNotificationsOpen] = useState(false); - const [notificationCount, setNotificationCount] = useState(0); - const [isAtTop, setIsAtTop] = useState(true); - const { showToast } = useToast(); - const timelineOptions = useMemo(() => getTimelineOptions(account?.platform, false), [account?.platform]); - const timelineButtonLabel = `타임라인 선택: ${getTimelineLabel(timelineType)}`; - const hasNotificationBadge = notificationCount > 0; - const instanceOriginUrl = useMemo(() => { - if (!account) { - return null; - } - try { - return normalizeInstanceUrl(account.instanceUrl); - } catch { - return null; - } - }, [account]); - const notificationBadgeLabel = notificationsOpen - ? "알림 닫기" - : hasNotificationBadge - ? `알림 열기 (새 알림 ${notificationCount >= 99 ? "99개 이상" : `${notificationCount}개`})` - : "알림 열기"; - const notificationBadgeText = notificationCount >= 99 ? "99+" : String(notificationCount); - const handleNotification = useCallback(() => { - if (notificationsOpen) { - refreshNotifications(); - return; - } - setNotificationCount((count) => Math.min(count + 1, 99)); - if (timelineType === "notifications") { - return; - } - const now = Date.now(); - if (now - lastNotificationToastRef.current < 5000) { - return; - } - lastNotificationToastRef.current = now; - showToast("새 알림이 도착했습니다.", { - tone: "info", - actionLabel: "알림 받은 컬럼으로 이동", - actionAriaLabel: "알림이 도착한 컬럼으로 이동", - onAction: () => onScrollToSection(section.id) - }); - }, [notificationsOpen, refreshNotifications, timelineType, showToast, onScrollToSection, section.id]); - const timeline = useTimeline({ - account, - api: services.api, - streaming: services.streaming, - timelineType, - onNotification: handleNotification - }); - const actionsDisabled = timelineType === "notifications" || timelineType === "bookmarks"; - const emptyMessage = timelineType === "notifications" - ? "표시할 알림이 없습니다." - : timelineType === "bookmarks" - ? "북마크한 글이 없습니다." - : "표시할 글이 없습니다."; - - useEffect(() => { - if (!timeline.error) { - return; - } - showToast(timeline.error, { tone: "error" }); - }, [showToast, timeline.error]); - - useEffect(() => { - const el = scrollRef.current; - if (!el) { - return; - } - const onScroll = () => { - const threshold = el.scrollHeight - el.clientHeight - 200; - if (el.scrollTop >= threshold) { - timeline.loadMore(); - } - setIsAtTop(el.scrollTop <= 0); - }; - onScroll(); - el.addEventListener("scroll", onScroll, { passive: true }); - return () => { - el.removeEventListener("scroll", onScroll); - }; - }, [timeline.loadMore]); - - useEffect(() => { - if (!account || timelineType === "notifications") { - return; - } - registerTimelineListener(account.id, timeline.updateItem); - return () => { - unregisterTimelineListener(account.id, timeline.updateItem); - }; - }, [account, registerTimelineListener, timeline.updateItem, timelineType, unregisterTimelineListener]); - - useClickOutside(menuRef, menuOpen, () => setMenuOpen(false)); - - useClickOutside(timelineMenuRef, timelineMenuOpen, () => setTimelineMenuOpen(false)); - - useClickOutside(notificationMenuRef, notificationsOpen, () => setNotificationsOpen(false)); - - useEffect(() => { - if (!notificationsOpen) { - return; - } - const el = notificationScrollRef.current; - if (!el) { - return; - } - const onScroll = () => { - const threshold = el.scrollHeight - el.clientHeight - 120; - if (el.scrollTop >= threshold) { - loadMoreNotifications(); - } - }; - onScroll(); - el.addEventListener("scroll", onScroll, { passive: true }); - return () => { - el.removeEventListener("scroll", onScroll); - }; - }, [notificationsOpen, loadMoreNotifications]); - - useEffect(() => { - if (!account) { - setNotificationsOpen(false); - setTimelineMenuOpen(false); - } - setNotificationCount(0); - }, [account?.id]); - - useEffect(() => { - if (!notificationsOpen) { - return; - } - setNotificationCount(0); - refreshNotifications(); - }, [notificationsOpen, refreshNotifications]); - - useEffect(() => { - if (!notificationsError) { - return; - } - showToast(notificationsError, { tone: "error" }); - }, [notificationsError, showToast]); - - const handleToggleFavourite = async (status: Status) => { - if (!account) { - onError("계정을 선택해주세요."); - return; - } - onError(null); - try { - const updated = status.favourited - ? await services.api.unfavourite(account, status.id) - : await services.api.favourite(account, status.id); - timeline.updateItem(updated); - } catch (err) { - onError(err instanceof Error ? err.message : "좋아요 처리에 실패했습니다."); - } - }; - - const handleToggleReblog = async (status: Status) => { - if (!account) { - onError("계정을 선택해주세요."); - return; - } - onError(null); - const delta = status.reblogged ? -1 : 1; - const optimistic = { - ...status, - reblogged: !status.reblogged, - reblogsCount: Math.max(0, status.reblogsCount + delta) - }; - timeline.updateItem(optimistic); - try { - const updated = status.reblogged - ? await services.api.unreblog(account, status.id) - : await services.api.reblog(account, status.id); - timeline.updateItem(updated); - } catch (err) { - onError(err instanceof Error ? err.message : "부스트 처리에 실패했습니다."); - timeline.updateItem(status); - } - }; - - 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); - }, - [account, onReact] - ); - - const handleDeleteStatus = async (status: Status) => { - if (!account) { - return; - } - onError(null); - try { - await services.api.deleteStatus(account, status.id); - timeline.removeItem(status.id); - onCloseStatusModal(); - } catch (err) { - onError(err instanceof Error ? err.message : "게시글 삭제에 실패했습니다."); - } - }; - - const scrollToTop = () => { - if (scrollRef.current) { - scrollRef.current.scrollTo({ top: 0, behavior: "smooth" }); - } - }; - - const handleOpenInstanceOrigin = useCallback(() => { - if (!instanceOriginUrl) { - return; - } - window.open(instanceOriginUrl, "_blank", "noopener,noreferrer"); - setMenuOpen(false); - }, [instanceOriginUrl]); - - return ( -
-
- { - onAccountChange(section.id, id); - accountsState.setActiveAccount(id); - }} - variant="inline" - /> -
-
- - {timelineMenuOpen ? ( - <> - - ); -}; - -type ThemeMode = "default" | "christmas" | "sky-pink" | "monochrome" | "matcha-core"; - -const isThemeMode = (value: string): value is ThemeMode => - value === "default" || - value === "christmas" || - value === "sky-pink" || - value === "monochrome" || - value === "matcha-core"; - -const getStoredTheme = (): ThemeMode => { - const storedTheme = localStorage.getItem("textodon.theme"); - if (storedTheme && isThemeMode(storedTheme)) { - return storedTheme; - } - return localStorage.getItem("textodon.christmas") === "on" ? "christmas" : "default"; -}; - -type ColorScheme = "system" | "light" | "dark"; - -const isColorScheme = (value: string): value is ColorScheme => - value === "system" || value === "light" || value === "dark"; - -const getStoredColorScheme = (): ColorScheme => { - const storedScheme = localStorage.getItem("textodon.colorScheme"); - if (storedScheme && isColorScheme(storedScheme)) { - return storedScheme; - } - return "system"; -}; - export const App = () => { const [themeMode, setThemeMode] = useState(() => getStoredTheme()); const [colorScheme, setColorScheme] = useState(() => getStoredColorScheme()); @@ -1024,6 +135,7 @@ export const App = () => { ); const [replyTarget, setReplyTarget] = useState(null); const [selectedStatus, setSelectedStatus] = useState(null); + const [selectedTimelineStatus, setSelectedTimelineStatus] = useState(null); const [profileTargets, setProfileTargets] = useState([]); const [statusModalZIndex, setStatusModalZIndex] = useState(null); const nextModalZIndexRef = useRef(70); @@ -1032,8 +144,10 @@ export const App = () => { const [mentionSeed, setMentionSeed] = useState(null); const timelineBoardRef = useRef(null); const sectionRefs = useRef>(new Map()); - const dragStateRef = useRef<{ startX: number; scrollLeft: number; pointerId: number } | null>(null); - const [isBoardDragging, setIsBoardDragging] = useState(false); + const sectionItemsRef = useRef>(new Map()); + const timelineShortcutHandlersRef = useRef boolean>>( + new Map() + ); const replySummary = replyTarget ? `@${formatReplyHandle(replyTarget.accountHandle, replyTarget.accountUrl, composeAccount?.instanceUrl ?? "")} · ${replyTarget.content.slice(0, 80)}` : null; @@ -1093,6 +207,36 @@ export const App = () => { [broadcastStatusUpdate] ); + const handleTimelineItemsChange = useCallback((sectionId: string, items: Status[]) => { + sectionItemsRef.current.set(sectionId, items); + setSelectedTimelineStatus((current) => { + if (!current || current.sectionId !== sectionId) { + return current; + } + return items.some((item) => item.id === current.statusId) ? current : null; + }); + }, []); + + const handleSelectStatus = useCallback((sectionId: string, statusId: string) => { + setSelectedTimelineStatus((current) => { + if (current && current.sectionId === sectionId && current.statusId === statusId) { + return null; + } + return { sectionId, statusId }; + }); + }, []); + + const registerTimelineShortcutHandler = useCallback( + (sectionId: string, handler: ((event: KeyboardEvent) => boolean) | null) => { + if (!handler) { + timelineShortcutHandlersRef.current.delete(sectionId); + return; + } + timelineShortcutHandlersRef.current.set(sectionId, handler); + }, + [] + ); + useEffect(() => { const onHashChange = () => setRoute(parseRoute()); window.addEventListener("hashchange", onHashChange); @@ -1280,6 +424,8 @@ export const App = () => { localStorage.setItem("textodon.pomodoro.longBreak", String(pomodoroLongBreak)); }, [pomodoroLongBreak]); + + const closeMobileMenu = useCallback(() => { setMobileMenuOpen(false); setMobileComposeOpen(false); @@ -1337,65 +483,215 @@ export const App = () => { window.location.reload(); }, []); - const isInteractiveTarget = useCallback((target: EventTarget | null) => { - const element = - target instanceof Element - ? target - : target && "parentElement" in target - ? (target as Node).parentElement - : null; + const isEditableElement = useCallback((element: Element | null) => { if (!element) { return false; } - return Boolean( - element.closest( - "button, a, input, textarea, select, label, summary, details, [role='button'], [contenteditable='true'], [data-interactive='true']" - ) + return ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + (element as HTMLElement).isContentEditable ); }, []); - const handleBoardPointerDown = useCallback( - (event: React.PointerEvent) => { - if (event.button !== 0 || !timelineBoardRef.current) { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) { return; } - if (isInteractiveTarget(event.target)) { + const hasOverlayBackdrop = document.querySelector( + ".overlay-backdrop, .image-modal, .confirm-modal, .profile-modal, .status-modal, .settings-modal, .info-modal" + ); + if (selectedStatus || settingsOpen || infoModal || mobileMenuOpen || mobileComposeOpen) { + return; + } + if (profileTargets.length > 0) { + return; + } + if (isEditableElement(document.activeElement)) { return; } - dragStateRef.current = { - startX: event.clientX, - scrollLeft: timelineBoardRef.current.scrollLeft, - pointerId: event.pointerId - }; - setIsBoardDragging(true); - timelineBoardRef.current.setPointerCapture(event.pointerId); - }, - [isInteractiveTarget] - ); - const handleBoardPointerMove = useCallback((event: React.PointerEvent) => { - if (!timelineBoardRef.current || !dragStateRef.current) { - return; - } - if (event.pointerId !== dragStateRef.current.pointerId) { - return; - } - const delta = event.clientX - dragStateRef.current.startX; - timelineBoardRef.current.scrollLeft = dragStateRef.current.scrollLeft - delta; - event.preventDefault(); - }, []); + const key = event.key; + if (key === "Escape") { + if (hasOverlayBackdrop) { + return; + } + if (selectedTimelineStatus) { + const keyHandledByTimeline = timelineShortcutHandlersRef.current.get( + selectedTimelineStatus.sectionId + )?.(event); + if (keyHandledByTimeline) { + return; + } + } + if (selectedTimelineStatus) { + event.preventDefault(); + setSelectedTimelineStatus(null); + } + return; + } - const handleBoardPointerUp = useCallback((event: React.PointerEvent) => { - if (!timelineBoardRef.current || !dragStateRef.current) { - return; - } - if (event.pointerId !== dragStateRef.current.pointerId) { - return; - } - timelineBoardRef.current.releasePointerCapture(event.pointerId); - dragStateRef.current = null; - setIsBoardDragging(false); - }, []); + if (selectedTimelineStatus) { + const keyHandledByTimeline = timelineShortcutHandlersRef.current.get( + selectedTimelineStatus.sectionId + )?.(event); + if (keyHandledByTimeline) { + return; + } + } + + if ( + key.toLowerCase() === "m" && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey + ) { + if (selectedTimelineStatus) { + return; + } + const board = timelineBoardRef.current; + if (!board) { + return; + } + const boardRect = board.getBoundingClientRect(); + let leftmostSectionId: string | null = null; + let leftmostPosition = Number.POSITIVE_INFINITY; + sections.forEach((section) => { + const element = sectionRefs.current.get(section.id); + if (!element) { + return; + } + const rect = element.getBoundingClientRect(); + if (rect.right <= boardRect.left || rect.left >= boardRect.right) { + return; + } + if (rect.left < leftmostPosition) { + leftmostPosition = rect.left; + leftmostSectionId = section.id; + } + }); + if (!leftmostSectionId) { + return; + } + const items = sectionItemsRef.current.get(leftmostSectionId) ?? []; + if (items.length === 0) { + return; + } + event.preventDefault(); + setSelectedTimelineStatus({ sectionId: leftmostSectionId, statusId: items[0].id }); + return; + } + + if (!selectedTimelineStatus) { + return; + } + + if ( + hasOverlayBackdrop && + (key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight") + ) { + return; + } + + const currentItems = sectionItemsRef.current.get(selectedTimelineStatus.sectionId) ?? []; + const currentIndex = currentItems.findIndex( + (item) => item.id === selectedTimelineStatus.statusId + ); + + if (key === "ArrowUp" || key === "ArrowDown") { + if (currentItems.length === 0 || currentIndex === -1) { + return; + } + const nextIndex = key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; + if (nextIndex < 0 || nextIndex >= currentItems.length) { + return; + } + event.preventDefault(); + setSelectedTimelineStatus({ + sectionId: selectedTimelineStatus.sectionId, + statusId: currentItems[nextIndex].id + }); + return; + } + + if (key === "ArrowLeft" || key === "ArrowRight") { + const currentSectionIndex = sections.findIndex( + (section) => section.id === selectedTimelineStatus.sectionId + ); + if (currentSectionIndex === -1) { + return; + } + const currentSectionElement = sectionRefs.current.get(selectedTimelineStatus.sectionId); + const currentStatusElement = currentSectionElement?.querySelector( + `[data-status-id="${selectedTimelineStatus.statusId}"]` + ); + const currentCenterY = currentStatusElement + ? currentStatusElement.getBoundingClientRect().top + + currentStatusElement.getBoundingClientRect().height / 2 + : null; + const direction = key === "ArrowLeft" ? -1 : 1; + let targetIndex = currentSectionIndex + direction; + while (targetIndex >= 0 && targetIndex < sections.length) { + const targetSection = sections[targetIndex]; + const items = sectionItemsRef.current.get(targetSection.id) ?? []; + if (items.length > 0) { + let nextStatusId = items[ + Math.min(currentIndex >= 0 ? currentIndex : 0, items.length - 1) + ]?.id; + if (currentCenterY !== null) { + const targetSectionElement = sectionRefs.current.get(targetSection.id); + const statusElements = targetSectionElement?.querySelectorAll( + "[data-status-id]" + ); + if (statusElements && statusElements.length > 0) { + let closestMatch: { id: string; distance: number } | null = null; + for (const element of Array.from(statusElements)) { + const statusId = element.dataset.statusId; + if (!statusId) { + continue; + } + const rect = element.getBoundingClientRect(); + const centerY = rect.top + rect.height / 2; + const distance = Math.abs(centerY - currentCenterY); + if (!closestMatch || distance < closestMatch.distance) { + closestMatch = { id: statusId, distance }; + } + } + if (closestMatch) { + nextStatusId = closestMatch.id; + } + } + } + if (!nextStatusId) { + return; + } + event.preventDefault(); + setSelectedTimelineStatus({ + sectionId: targetSection.id, + statusId: nextStatusId + }); + return; + } + targetIndex += direction; + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + infoModal, + isEditableElement, + mobileComposeOpen, + mobileMenuOpen, + profileTargets.length, + sections, + selectedStatus, + selectedTimelineStatus, + settingsOpen + ]); const scrollToSection = useCallback((sectionId: string) => { const target = sectionRefs.current.get(sectionId); @@ -1405,6 +701,26 @@ export const App = () => { target.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" }); }, []); + useEffect(() => { + if (!selectedTimelineStatus) { + return; + } + scrollToSection(selectedTimelineStatus.sectionId); + requestAnimationFrame(() => { + const section = sectionRefs.current.get(selectedTimelineStatus.sectionId); + if (!section) { + return; + } + const statusElement = section.querySelector( + `[data-status-id="${selectedTimelineStatus.statusId}"]` + ); + if (!statusElement) { + return; + } + statusElement.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + }, [scrollToSection, selectedTimelineStatus]); + useEffect(() => { setSections((current) => current.map((section) => { @@ -1454,6 +770,15 @@ export const App = () => { previousAccountIds.current = currentIds; }, [accountsState.accounts]); + useEffect(() => { + if (!selectedTimelineStatus) { + return; + } + if (!sections.some((section) => section.id === selectedTimelineStatus.sectionId)) { + setSelectedTimelineStatus(null); + } + }, [sections, selectedTimelineStatus]); + const handleSubmit = async (params: { text: string; visibility: "public" | "unlisted" | "private" | "direct"; @@ -1689,6 +1014,14 @@ export const App = () => {
+
+
+

모바일 환경에서는 사용이 불가능합니다 🙇‍♂️

+

+ 멀티 컬럼 인터페이스 특성상 모바일 지원이 제한됩니다. 데스크톱 또는 태블릿에서 이용해 주세요. +

+
+
@@ -1872,296 +1217,67 @@ onAccountChange={setSectionAccount} setInfoModal(null)} /> ) : null} - {mobileComposeOpen ? ( -
-
setMobileComposeOpen(false)} /> -
-
-

글쓰기

- -
- {composeAccount ? ( - { - setReplyTarget(null); - setMentionSeed(null); - }} - mentionText={mentionSeed} - /> - ) : null} -
-
- ) : null} - - {mobileMenuOpen ? ( -
-
-
-
-

메뉴

- -
-
- -
-
- -
-
-
- ) : null} - - {settingsOpen ? ( -
-
setSettingsOpen(false)} /> -
-
-

설정

- -
-
-
- 계정 관리 -

계정을 선택하여 재인증하거나 삭제합니다.

-
-
- -
- - -
-
-
-
-
- 테마 -

기본, 크리스마스, 하늘핑크, 모노톤 테마를 선택합니다.

-
- -
-
-
- 색상 모드 -

시스템 설정을 따르거나 라이트/다크 모드를 고정합니다.

-
- -
-
-
- 프로필 이미지 표시 -

피드에서 사용자 프로필 이미지를 보여줍니다.

-
- -
-
-
- 커스텀 이모지 표시 -

사용자 이름과 본문에 커스텀 이모지를 표시합니다.

-
- -
-
-
- 리액션 표시 -

리액션 정보를 지원하는 서버에서 받은 리액션을 보여줍니다.

-
- -
-
-
- 섹션 폭 -

타임라인 섹션의 가로 폭을 조절합니다.

-
- -
-
-
- 뽀모도로 타이머 -

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

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

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

-
-
- - - -
-
- ) : null} -
-
- 로컬 저장소 초기화 -

계정과 설정을 포함한 모든 로컬 데이터를 삭제합니다.

-
- -
-
-
- ) : null} + setMobileComposeOpen(false)} + composeAccount={composeAccount} + composeAccountSelector={composeAccountSelector} + api={services.api} + onSubmit={handleSubmit} + replyingTo={replyTarget ? { id: replyTarget.id, summary: replySummary ?? "" } : null} + onCancelReply={() => { + setReplyTarget(null); + setMentionSeed(null); + }} + mentionText={mentionSeed} + /> + + setSettingsOpen(true)} + oauth={services.oauth} + /> + + setSettingsOpen(false)} + accountsState={accountsState} + settingsAccountId={settingsAccountId} + setSettingsAccountId={setSettingsAccountId} + reauthLoading={reauthLoading} + onReauth={handleSettingsReauth} + onRemove={handleSettingsRemove} + onClearLocalStorage={handleClearLocalStorage} + themeMode={themeMode} + onThemeChange={(value) => { + if (isThemeMode(value)) { + setThemeMode(value); + } + }} + colorScheme={colorScheme} + onColorSchemeChange={(value) => { + if (isColorScheme(value)) { + setColorScheme(value); + } + }} + showProfileImages={showProfileImages} + onToggleProfileImages={setShowProfileImages} + showCustomEmojis={showCustomEmojis} + onToggleCustomEmojis={setShowCustomEmojis} + showMisskeyReactions={showMisskeyReactions} + onToggleMisskeyReactions={setShowMisskeyReactions} + sectionSize={sectionSize} + onSectionSizeChange={setSectionSize} + showPomodoro={showPomodoro} + onTogglePomodoro={setShowPomodoro} + pomodoroFocus={pomodoroFocus} + pomodoroBreak={pomodoroBreak} + pomodoroLongBreak={pomodoroLongBreak} + onPomodoroFocusChange={setPomodoroFocus} + onPomodoroBreakChange={setPomodoroBreak} + onPomodoroLongBreakChange={setPomodoroLongBreak} + /> {profileTargets.map((target, index) => ( { + 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/services/MastodonApi.ts b/src/services/MastodonApi.ts index 2f63add..54fa689 100644 --- a/src/services/MastodonApi.ts +++ b/src/services/MastodonApi.ts @@ -40,4 +40,8 @@ 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/AccountSelector.tsx b/src/ui/components/AccountSelector.tsx index 47766f1..f4d9acb 100644 --- a/src/ui/components/AccountSelector.tsx +++ b/src/ui/components/AccountSelector.tsx @@ -1,5 +1,6 @@ -import React, { useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import type { Account } from "../../domain/types"; +import type { Ref } from "react"; import { formatHandle } from "../utils/account"; import { useClickOutside } from "../hooks/useClickOutside"; import { AccountLabel } from "./AccountLabel"; @@ -8,15 +9,24 @@ export const AccountSelector = ({ accounts, activeAccountId, setActiveAccount, + onSelectionDone, + summaryRef, + summaryTitle, variant = "panel" }: { accounts: Account[]; activeAccountId: string | null; setActiveAccount: (id: string) => void; + onSelectionDone?: () => void; + summaryRef?: Ref; + summaryTitle?: string; variant?: "panel" | "inline"; }) => { const [dropdownOpen, setDropdownOpen] = useState(false); + const [highlightedAccountId, setHighlightedAccountId] = useState(null); + const detailsRef = useRef(null); const dropdownRef = useRef(null); + const selectionChangeRef = useRef(false); useClickOutside(dropdownRef, dropdownOpen, () => setDropdownOpen(false)); @@ -25,6 +35,75 @@ export const AccountSelector = ({ [accounts, activeAccountId] ); + useEffect(() => { + if (!dropdownOpen) { + setHighlightedAccountId(null); + return; + } + setHighlightedAccountId(activeAccountId ?? accounts[0]?.id ?? null); + }, [activeAccountId, accounts, dropdownOpen]); + + useEffect(() => { + if (!dropdownOpen && selectionChangeRef.current) { + selectionChangeRef.current = false; + onSelectionDone?.(); + } + }, [dropdownOpen, onSelectionDone]); + + useEffect(() => { + if (!dropdownOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (!dropdownOpen) { + return; + } + if (!detailsRef.current?.contains(document.activeElement)) { + return; + } + if (accounts.length === 0) { + return; + } + + const currentIndex = Math.max( + 0, + accounts.findIndex((account) => account.id === (highlightedAccountId ?? activeAccountId)) + ); + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const offset = event.key === "ArrowDown" ? 1 : -1; + const nextIndex = (currentIndex + offset + accounts.length) % accounts.length; + const nextAccount = accounts[nextIndex]; + if (nextAccount) { + setHighlightedAccountId(nextAccount.id); + selectionChangeRef.current = true; + setActiveAccount(nextAccount.id); + } + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + if (highlightedAccountId) { + selectionChangeRef.current = true; + setActiveAccount(highlightedAccountId); + } + setDropdownOpen(false); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setDropdownOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [accounts, activeAccountId, dropdownOpen, highlightedAccountId, setActiveAccount]); + const wrapperClassName = variant === "panel" ? "panel account-selector-panel" : "account-selector-inline"; const Wrapper = variant === "panel" ? "section" : "div"; @@ -33,11 +112,16 @@ export const AccountSelector = ({
setDropdownOpen(event.currentTarget.open)} > - + {activeAccount ? ( {accounts.map((account) => { const isActiveAccount = account.id === activeAccountId; + const classNames = [] as string[]; + if (account.id === highlightedAccountId) { + classNames.push("is-highlighted"); + } + if (isActiveAccount) { + classNames.push("active"); + } return ( -
  • +
  • +
  • + ))} +
    + ) : null} +
    { + event.preventDefault(); + handleAddTodo(); + }} + > + setTodoInput(event.target.value)} + placeholder="할 일 추가" + aria-label="뽀모도로 투두 입력" + /> + +
    +
    ); }; diff --git a/src/ui/components/ReactionPicker.tsx b/src/ui/components/ReactionPicker.tsx index 18f507b..70f6728 100644 --- a/src/ui/components/ReactionPicker.tsx +++ b/src/ui/components/ReactionPicker.tsx @@ -9,12 +9,14 @@ export const ReactionPicker = ({ account, api, disabled = false, - onSelect + onSelect, + buttonDataAction }: { account: Account | null; api: MastodonApi; disabled?: boolean; onSelect: (reaction: ReactionInput) => void; + buttonDataAction?: string; }) => { const [open, setOpen] = useState(false); const [panelStyle, setPanelStyle] = useState({}); @@ -166,6 +168,7 @@ export const ReactionPicker = ({ onClick={() => setOpen((current) => !current)} disabled={disabled} ref={buttonRef} + data-action={buttonDataAction} aria-label="리액션 추가" aria-haspopup="dialog" aria-expanded={open} diff --git a/src/ui/components/SettingsModal.tsx b/src/ui/components/SettingsModal.tsx new file mode 100644 index 0000000..37f6395 --- /dev/null +++ b/src/ui/components/SettingsModal.tsx @@ -0,0 +1,288 @@ +import type { AccountsState } from "../state/AppContext"; +import type { ColorScheme, ThemeMode } from "../utils/theme"; +import { AccountSelector } from "./AccountSelector"; + +type SettingsModalProps = { + open: boolean; + onClose: () => void; + accountsState: AccountsState; + settingsAccountId: string | null; + setSettingsAccountId: (id: string | null) => void; + reauthLoading: boolean; + onReauth: () => void; + onRemove: () => void; + onClearLocalStorage: () => void; + themeMode: ThemeMode; + onThemeChange: (value: string) => void; + colorScheme: ColorScheme; + onColorSchemeChange: (value: string) => void; + showProfileImages: boolean; + onToggleProfileImages: (value: boolean) => void; + showCustomEmojis: boolean; + onToggleCustomEmojis: (value: boolean) => void; + showMisskeyReactions: boolean; + onToggleMisskeyReactions: (value: boolean) => void; + sectionSize: "small" | "medium" | "large"; + onSectionSizeChange: (value: "small" | "medium" | "large") => void; + showPomodoro: boolean; + onTogglePomodoro: (value: boolean) => void; + pomodoroFocus: number; + pomodoroBreak: number; + pomodoroLongBreak: number; + onPomodoroFocusChange: (value: number) => void; + onPomodoroBreakChange: (value: number) => void; + onPomodoroLongBreakChange: (value: number) => void; +}; + +export const SettingsModal = ({ + open, + onClose, + accountsState, + settingsAccountId, + setSettingsAccountId, + reauthLoading, + onReauth, + onRemove, + onClearLocalStorage, + themeMode, + onThemeChange, + colorScheme, + onColorSchemeChange, + showProfileImages, + onToggleProfileImages, + showCustomEmojis, + onToggleCustomEmojis, + showMisskeyReactions, + onToggleMisskeyReactions, + sectionSize, + onSectionSizeChange, + showPomodoro, + onTogglePomodoro, + pomodoroFocus, + pomodoroBreak, + pomodoroLongBreak, + onPomodoroFocusChange, + onPomodoroBreakChange, + onPomodoroLongBreakChange +}: SettingsModalProps) => { + if (!open) { + return null; + } + + return ( +
    +
    +
    +
    +

    설정

    + +
    +
    +
    +
    + 계정 관리 +

    계정을 선택하여 재인증하거나 삭제합니다.

    +
    +
    + +
    + + +
    +
    +
    +
    +
    + 테마 +

    기본, 크리스마스, 하늘핑크, 모노톤 테마를 선택합니다.

    +
    + +
    +
    +
    + 색상 모드 +

    시스템 설정을 따르거나 라이트/다크 모드를 고정합니다.

    +
    + +
    +
    +
    + 프로필 이미지 표시 +

    피드에서 사용자 프로필 이미지를 보여줍니다.

    +
    + +
    +
    +
    + 커스텀 이모지 표시 +

    사용자 이름과 본문에 커스텀 이모지를 표시합니다.

    +
    + +
    +
    +
    + 리액션 표시 +

    리액션 정보를 지원하는 서버에서 받은 리액션을 보여줍니다.

    +
    + +
    +
    +
    + 섹션 폭 +

    타임라인 섹션의 가로 폭을 조절합니다.

    +
    + +
    +
    +
    + 뽀모도로 타이머 +

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

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

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

    +
    +
    + + + +
    +
    + + ) : null} +
    +
    + 로컬 저장소 초기화 +

    계정과 설정을 포함한 모든 로컬 데이터를 삭제합니다.

    +
    + +
    +
    +
    +
    + ); +}; diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index 773de1d..86a3be4 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(); @@ -34,6 +35,8 @@ export const TimelineItem = ({ onReact, onProfileClick, onStatusClick, + onSelect, + isSelected = false, account, api, activeHandle, @@ -54,6 +57,8 @@ export const TimelineItem = ({ onReact?: (status: Status, reaction: ReactionInput) => void; onProfileClick?: (status: Status) => void; onStatusClick?: (status: Status) => void; + onSelect?: (statusId: string) => void; + isSelected?: boolean; account: Account | null; api: MastodonApi; activeHandle: string; @@ -71,10 +76,51 @@ 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 handleSelect = useCallback( + (event: React.MouseEvent) => { + if (!onSelect) { + return; + } + if (event.defaultPrevented) { + return; + } + const target = event.target instanceof Element ? event.target : null; + if ( + target?.closest( + "button, a, input, textarea, select, label, summary, details, [role='button'], [role='link'], [contenteditable='true'], [data-interactive='true'], .overlay-backdrop, .image-modal, .confirm-modal" + ) + ) { + return; + } + onSelect(status.id); + }, + [onSelect, status.id] + ); + + // 메뉴 열 때 즐겨찾기 상태 확인 (미스키만) + 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 { @@ -753,7 +799,11 @@ export const TimelineItem = ({ ); return ( -
    +
    {notificationLabel ? (