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) => ( { + switch (type) { + case "terms": + return "이용약관"; + case "license": + return "라이선스"; + case "oss": + return "오픈소스 목록"; + case "shortcuts": + return "단축키"; + default: + return ""; + } +}; + +const InfoModalContent = ({ type }: { type: InfoModalType }) => { + switch (type) { + case "terms": + return ; + case "license": + return ; + case "oss": + return ; + case "shortcuts": + return ; + default: + return null; + } +}; + +export const InfoModal = ({ type, onClose }: { type: InfoModalType; onClose: () => void }) => { + const title = getInfoModalTitle(type); + return ( +
+
+
+
+

{title}

+ +
+
+ +
+
+
+ ); +}; diff --git a/src/ui/components/MobileMenus.tsx b/src/ui/components/MobileMenus.tsx new file mode 100644 index 0000000..81b3992 --- /dev/null +++ b/src/ui/components/MobileMenus.tsx @@ -0,0 +1,120 @@ +import type { ReactNode } from "react"; +import type { Account, Visibility } from "../../domain/types"; +import type { MastodonApi } from "../../services/MastodonApi"; +import type { OAuthClient } from "../../services/OAuthClient"; +import { AccountAdd } from "./AccountAdd"; +import { ComposeBox } from "./ComposeBox"; + +type MobileComposeMenuProps = { + open: boolean; + onClose: () => void; + composeAccount: Account | null; + composeAccountSelector: ReactNode; + api: MastodonApi; + onSubmit: (params: { + text: string; + visibility: Visibility; + inReplyToId?: string; + files: File[]; + spoilerText: string; + }) => Promise; + replyingTo: { id: string; summary: string } | null; + onCancelReply: () => void; + mentionText: string | null; +}; + +export const MobileComposeMenu = ({ + open, + onClose, + composeAccount, + composeAccountSelector, + api, + onSubmit, + replyingTo, + onCancelReply, + mentionText +}: MobileComposeMenuProps) => { + if (!open) { + return null; + } + + return ( +
+
+
+
+

글쓰기

+ +
+ {composeAccount ? ( + + ) : null} +
+
+ ); +}; + +type MobileMenuProps = { + open: boolean; + onClose: () => void; + onOpenSettings: () => void; + oauth: OAuthClient; +}; + +export const MobileMenu = ({ open, onClose, onOpenSettings, oauth }: MobileMenuProps) => { + if (!open) { + return null; + } + + return ( +
+
+
+
+

메뉴

+ +
+
+ +
+
+ +
+
+
+ ); +}; 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/TimelineSection.tsx b/src/ui/components/TimelineSection.tsx new file mode 100644 index 0000000..417fada --- /dev/null +++ b/src/ui/components/TimelineSection.tsx @@ -0,0 +1,1063 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Account, ReactionInput, Status, TimelineType } from "../../domain/types"; +import type { AccountsState, AppServices } from "../state/AppContext"; +import { useTimeline } from "../hooks/useTimeline"; +import { useClickOutside } from "../hooks/useClickOutside"; +import { useToast } from "../state/ToastContext"; +import { AccountSelector } from "./AccountSelector"; +import { TimelineItem } from "./TimelineItem"; +import { formatHandle, normalizeInstanceUrl } from "../utils/account"; +import { getTimelineLabel, getTimelineOptions } from "../utils/timeline"; + +export type TimelineSectionConfig = { id: string; accountId: string | null; timelineType: TimelineType }; + +type TimelineSectionProps = { + 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; + onMoveSection: (sectionId: string, direction: "left" | "right") => void; + onScrollToSection: (sectionId: string) => void; + onCloseStatusModal: () => void; + onTimelineItemsChange: (sectionId: string, items: Status[]) => void; + onSelectStatus: (sectionId: string, statusId: string) => 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; + registerTimelineShortcutHandler: (sectionId: string, handler: ((event: KeyboardEvent) => boolean) | null) => void; + columnRef?: React.Ref; + selectedStatusId: string | null; +}; + +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; + } +}; + +export const TimelineSection = ({ + section, + account, + services, + accountsState, + onAccountChange, + onTimelineChange, + onAddSectionLeft, + onAddSectionRight, + onRemoveSection, + onReply, + onStatusClick, + onCloseStatusModal, + onTimelineItemsChange, + onSelectStatus, + onReact, + onProfileClick, + onError, + onMoveSection, + onScrollToSection, + canMoveLeft, + canMoveRight, + canRemoveSection, + timelineType, + showProfileImage, + showCustomEmojis, + showReactions, + registerTimelineListener, + unregisterTimelineListener, + registerTimelineShortcutHandler, + columnRef, + selectedStatusId +}: TimelineSectionProps) => { + 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 accountSummaryRef = 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 [highlightedTimelineIndex, setHighlightedTimelineIndex] = useState(null); + const [highlightedSectionMenuIndex, setHighlightedSectionMenuIndex] = useState(null); + const [highlightedNotificationIndex, setHighlightedNotificationIndex] = useState(null); + const { showToast } = useToast(); + const timelineOptions = useMemo(() => getTimelineOptions(account?.platform, false), [account?.platform]); + const actionableTimelineOptions = useMemo( + () => timelineOptions.filter((option) => !option.isDivider), + [timelineOptions] + ); + 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(() => { + onTimelineItemsChange(section.id, timeline.items); + }, [onTimelineItemsChange, section.id, timeline.items]); + + 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 (!timelineMenuOpen) { + setHighlightedTimelineIndex(null); + return; + } + const selectedIndex = actionableTimelineOptions.findIndex((option) => option.id === timelineType); + const nextIndex = selectedIndex >= 0 ? selectedIndex : 0; + setHighlightedTimelineIndex(nextIndex); + }, [actionableTimelineOptions, timelineMenuOpen, timelineType]); + + useEffect(() => { + if (!menuOpen) { + setHighlightedSectionMenuIndex(null); + return; + } + setHighlightedSectionMenuIndex(0); + }, [menuOpen]); + + useEffect(() => { + if (!notificationsOpen) { + setHighlightedNotificationIndex(null); + return; + } + if (highlightedNotificationIndex !== null) { + return; + } + const hasNotifications = notificationItems.length > 0; + setHighlightedNotificationIndex(hasNotifications ? 0 : null); + }, [highlightedNotificationIndex, notificationItems.length, notificationsOpen]); + + useEffect(() => { + if (!notificationsOpen) { + return; + } + if (highlightedNotificationIndex === null) { + return; + } + const container = notificationScrollRef.current; + if (!container) { + return; + } + const items = container.querySelectorAll(".status"); + const target = items[highlightedNotificationIndex]; + if (!target) { + return; + } + target.scrollIntoView({ block: "nearest" }); + }, [highlightedNotificationIndex, notificationsOpen]); + + 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("북마크했습니다.", { tone: "success" }); + } else { + showToast("북마크를 취소했습니다.", { tone: "success" }); + } + } 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]); + + const handleTimelineShortcuts = useCallback( + (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + const hasModifier = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey; + if (key === "escape") { + if (timelineMenuOpen) { + event.preventDefault(); + setTimelineMenuOpen(false); + return true; + } + if (menuOpen) { + event.preventDefault(); + setMenuOpen(false); + return true; + } + if (notificationsOpen) { + event.preventDefault(); + setNotificationsOpen(false); + return true; + } + return false; + } + if (hasModifier) { + return false; + } + if (timelineMenuOpen && (key === "arrowup" || key === "arrowdown" || key === "enter")) { + if (!actionableTimelineOptions.length) { + return true; + } + if (key === "enter") { + const option = actionableTimelineOptions[ + highlightedTimelineIndex ?? 0 + ]; + if (option) { + onTimelineChange(section.id, option.id as TimelineType); + setTimelineMenuOpen(false); + } + event.preventDefault(); + return true; + } + event.preventDefault(); + setHighlightedTimelineIndex((current) => { + const currentIndex = current ?? 0; + const offset = key === "arrowdown" ? 1 : -1; + const nextIndex = + (currentIndex + offset + actionableTimelineOptions.length) % actionableTimelineOptions.length; + return nextIndex; + }); + return true; + } + if (menuOpen && (key === "arrowup" || key === "arrowdown" || key === "enter")) { + const menuButtons = menuRef.current?.querySelectorAll("button"); + if (!menuButtons || menuButtons.length === 0) { + return true; + } + if (key === "enter") { + const index = highlightedSectionMenuIndex ?? 0; + const targetButton = menuButtons[index]; + if (targetButton) { + targetButton.click(); + setMenuOpen(false); + } + event.preventDefault(); + return true; + } + event.preventDefault(); + setHighlightedSectionMenuIndex((current) => { + const currentIndex = current ?? 0; + const offset = key === "arrowdown" ? 1 : -1; + const nextIndex = (currentIndex + offset + menuButtons.length) % menuButtons.length; + return nextIndex; + }); + return true; + } + if (notificationsOpen && (key === "arrowup" || key === "arrowdown")) { + if (notificationItems.length === 0) { + return true; + } + event.preventDefault(); + setHighlightedNotificationIndex((current) => { + const currentIndex = current ?? 0; + if (key === "arrowup") { + return Math.max(0, currentIndex - 1); + } + return Math.min(notificationItems.length - 1, currentIndex + 1); + }); + return true; + } + if (notificationsOpen && key === "enter") { + if (highlightedNotificationIndex === null) { + return true; + } + const status = notificationItems[highlightedNotificationIndex]; + if (status) { + event.preventDefault(); + onStatusClick(status, account); + return true; + } + return true; + } + if (!selectedStatusId) { + return false; + } + const selectedStatus = timeline.items.find((item) => item.id === selectedStatusId); + if (!selectedStatus) { + return false; + } + const selectedStatusElement = scrollRef.current?.querySelector( + `[data-status-id="${selectedStatus.id}"]` + ); + const clickStatusAction = (action: string) => { + const button = selectedStatusElement?.querySelector( + `[data-action="${action}"]` + ); + if (!button || button.disabled) { + return false; + } + button.click(); + button.focus(); + return true; + }; + if (key === "r") { + if (actionsDisabled) { + return false; + } + const handled = clickStatusAction("reply"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (key === "b") { + if (actionsDisabled) { + return false; + } + const handled = clickStatusAction("reblog"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (key === "l") { + if (actionsDisabled) { + return false; + } + if (account?.platform === "mastodon") { + const handled = clickStatusAction("favourite"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (account?.platform === "misskey" && showReactions) { + event.preventDefault(); + onReact(account, selectedStatus, { + name: "❤️", + url: null, + isCustom: false, + host: null + }); + return true; + } + } + if (key === "c") { + if (account?.platform !== "misskey" || !showReactions) { + return false; + } + const handled = clickStatusAction("reaction-picker"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (key === "i") { + const handled = clickStatusAction("open-image"); + if (!handled) { + return false; + } + event.preventDefault(); + return true; + } + if (key === "enter") { + event.preventDefault(); + onStatusClick(selectedStatus, account); + return true; + } + if (key === "p") { + event.preventDefault(); + onProfileClick(selectedStatus, account); + return true; + } + if (key === "a") { + const summary = accountSummaryRef.current; + if (!summary) { + return false; + } + const details = summary.closest("details"); + if (details?.hasAttribute("open")) { + event.preventDefault(); + summary.focus(); + return true; + } + event.preventDefault(); + summary.click(); + summary.focus(); + return true; + } + if (key === "t") { + if (!account) { + onError("계정을 선택해주세요."); + return true; + } + event.preventDefault(); + setTimelineMenuOpen(true); + setMenuOpen(false); + setNotificationsOpen(false); + return true; + } + if (key === "g") { + if (!account) { + onError("계정을 선택해주세요."); + return true; + } + event.preventDefault(); + setNotificationsOpen((current) => !current); + setTimelineMenuOpen(false); + setMenuOpen(false); + return true; + } + if (key === "m") { + event.preventDefault(); + setMenuOpen(true); + setTimelineMenuOpen(false); + setNotificationsOpen(false); + return true; + } + return false; + }, + [ + account, + actionableTimelineOptions, + actionsDisabled, + highlightedNotificationIndex, + highlightedSectionMenuIndex, + highlightedTimelineIndex, + menuOpen, + notificationItems, + notificationItems.length, + notificationsOpen, + onError, + onProfileClick, + onReact, + onStatusClick, + onTimelineChange, + section.id, + selectedStatusId, + showReactions, + timeline.items, + timelineMenuOpen + ] + ); + + useEffect(() => { + registerTimelineShortcutHandler(section.id, handleTimelineShortcuts); + return () => registerTimelineShortcutHandler(section.id, null); + }, [handleTimelineShortcuts, registerTimelineShortcutHandler, section.id]); + + return ( +
+
+ { + onAccountChange(section.id, id); + accountsState.setActiveAccount(id); + }} + summaryRef={accountSummaryRef} + summaryTitle="계정 선택 (A)" + variant="inline" + /> +
+
+ + {timelineMenuOpen ? ( + <> + + ); +}; diff --git a/src/ui/content/shortcuts.ts b/src/ui/content/shortcuts.ts new file mode 100644 index 0000000..c1608b4 --- /dev/null +++ b/src/ui/content/shortcuts.ts @@ -0,0 +1,65 @@ +export const shortcutSections: Array<{ + title: string; + note?: string; + items: Array<{ keys: string; description: string }>; +}> = [ + { + title: "타임라인 이동", + items: [ + { keys: "M", description: "선택이 없을 때 왼쪽 첫 글을 선택" }, + { keys: "↑ / ↓", description: "선택된 글 위아래 이동" }, + { keys: "← / →", description: "이웃 컬럼으로 이동" }, + { keys: "ESC", description: "선택 해제" } + ] + }, + { + title: "선택된 글 컨트롤", + note: "글을 선택한 상태에서만 동작합니다.", + items: [ + { keys: "R", description: "답글 작성" }, + { keys: "B", description: "부스트" }, + { keys: "L", description: "좋아요 (마스토돈) / ❤️ 리액션 (미스키)" }, + { keys: "C", description: "리액션 팔레트 열기 (미스키)" }, + { keys: "I", description: "첨부 이미지 열기" }, + { keys: "Enter", description: "글 팝업 열기 (열린 메뉴에서는 항목 선택)" }, + { keys: "P", description: "작성자 프로필 팝업 열기" }, + { keys: "A", description: "계정 선택 열기" }, + { keys: "T", description: "타임라인 메뉴 열기" }, + { keys: "M", description: "컬럼 메뉴 열기" }, + { keys: "G", description: "알림 열기" }, + { keys: "↑ / ↓", description: "열린 메뉴에서 항목 이동" }, + { keys: "ESC", description: "열린 메뉴 닫기" } + ] + }, + { + title: "글쓰기", + note: "글쓰기 영역 기준으로 동작합니다.", + items: [ + { keys: "N", description: "글쓰기 입력으로 이동" }, + { keys: "Ctrl+Shift+N", description: "글쓰기 입력으로 이동 (포커스 중)" }, + { keys: "Ctrl+Shift+W", description: "내용 경고 토글" }, + { keys: "Ctrl+Shift+A", description: "계정 선택 열기" }, + { keys: "Ctrl+Shift+O", description: "공개 범위 선택" }, + { keys: "Ctrl+Shift+I", description: "미디어 첨부" }, + { keys: "Ctrl+Shift+E", description: "이모지 패널 토글" }, + { keys: "Ctrl/Command+Enter", description: "글 올리기" }, + { keys: "ESC", description: "글쓰기 입력 포커스 해제" } + ] + }, + { + title: "이모지 추천", + note: "추천 목록이 열려 있을 때만 동작합니다.", + items: [ + { keys: "↑ / ↓", description: "추천 항목 이동" }, + { keys: "Enter", description: "추천 이모지 입력" }, + { keys: "ESC", description: "추천 닫기" } + ] + }, + { + title: "이미지 뷰어", + items: [ + { keys: "← / →", description: "이미지 이동" }, + { keys: "ESC", description: "이미지 보기 닫기" } + ] + } +]; diff --git a/src/ui/pages/InfoPages.tsx b/src/ui/pages/InfoPages.tsx new file mode 100644 index 0000000..b2e5b37 --- /dev/null +++ b/src/ui/pages/InfoPages.tsx @@ -0,0 +1,78 @@ +import { sanitizeHtml } from "../utils/htmlSanitizer"; +import { renderMarkdown } from "../utils/markdown"; +import { shortcutSections } from "../content/shortcuts"; +import licenseText from "../../../LICENSE?raw"; +import ossMarkdown from "../content/oss.md?raw"; +import termsMarkdown from "../content/terms.md?raw"; + +const termsHtml = sanitizeHtml(renderMarkdown(termsMarkdown)); +const ossHtml = sanitizeHtml(renderMarkdown(ossMarkdown)); + +export const PageHeader = ({ title }: { title: string }) => ( + +); + +export const TermsContent = () => ( +
+); + +export const LicenseContent = () =>
{licenseText}
; + +export const OssContent = () => ( +
+); + +export const ShortcutsContent = () => ( +
+ {shortcutSections.map((section) => ( +
+

{section.title}

+ {section.note ?

{section.note}

: null} +
    + {section.items.map((item) => ( +
  • + {item.keys} + {item.description} +
  • + ))} +
+
+ ))} +
+); + +export const TermsPage = () => ( +
+ + +
+); + +export const LicensePage = () => ( +
+ + +
+); + +export const OssPage = () => ( +
+ + +
+); + +export const ShortcutsPage = () => ( +
+ + +
+); diff --git a/src/ui/types/info.ts b/src/ui/types/info.ts new file mode 100644 index 0000000..9977c5d --- /dev/null +++ b/src/ui/types/info.ts @@ -0,0 +1 @@ +export type InfoModalType = "terms" | "license" | "oss" | "shortcuts"; diff --git a/src/ui/utils/reactions.ts b/src/ui/utils/reactions.ts new file mode 100644 index 0000000..3008c19 --- /dev/null +++ b/src/ui/utils/reactions.ts @@ -0,0 +1,80 @@ +import type { Reaction, ReactionInput, Status } from "../../domain/types"; + +export const sortReactions = (reactions: Reaction[]) => + [...reactions].sort((a, b) => { + if (a.count === b.count) { + return a.name.localeCompare(b.name); + } + return b.count - a.count; + }); + +export const buildReactionSignature = (reactions: Reaction[]) => + sortReactions(reactions).map((reaction) => + [reaction.name, reaction.count, reaction.url ?? "", reaction.isCustom ? "1" : "0", reaction.host ?? ""].join("|") + ); + +export 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]); +}; + +export 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; +}; + +export 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) + }; +}; diff --git a/src/ui/utils/theme.ts b/src/ui/utils/theme.ts new file mode 100644 index 0000000..6973828 --- /dev/null +++ b/src/ui/utils/theme.ts @@ -0,0 +1,29 @@ +export type ThemeMode = "default" | "christmas" | "sky-pink" | "monochrome" | "matcha-core"; + +export const isThemeMode = (value: string): value is ThemeMode => + value === "default" || + value === "christmas" || + value === "sky-pink" || + value === "monochrome" || + value === "matcha-core"; + +export const getStoredTheme = (): ThemeMode => { + const storedTheme = localStorage.getItem("textodon.theme"); + if (storedTheme && isThemeMode(storedTheme)) { + return storedTheme; + } + return localStorage.getItem("textodon.christmas") === "on" ? "christmas" : "default"; +}; + +export type ColorScheme = "system" | "light" | "dark"; + +export const isColorScheme = (value: string): value is ColorScheme => + value === "system" || value === "light" || value === "dark"; + +export const getStoredColorScheme = (): ColorScheme => { + const storedScheme = localStorage.getItem("textodon.colorScheme"); + if (storedScheme && isColorScheme(storedScheme)) { + return storedScheme; + } + return "system"; +};