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 }) => (
-
-);
-
-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 ? (
- <>
-
-
- {timelineOptions.map((option) => {
- if (option.isDivider) {
- return (
-
- );
- }
-
- const isSelected = !option.isDivider && timelineType === option.id;
- return (
-
- );
- })}
-
- >
- ) : null}
-
-
-
- {notificationsOpen ? (
- <>
-
-
-
- {notificationItems.length === 0 && !notificationsLoading ? (
-
표시할 알림이 없습니다.
- ) : null}
- {notificationsLoading && notificationItems.length === 0 ? (
-
알림을 불러오는 중...
- ) : null}
- {notificationItems.length > 0 ? (
-
- {notificationItems.map((status) => (
- onReply(item, account)}
- onStatusClick={(status) => onStatusClick(status, account)}
- onToggleFavourite={handleToggleFavourite}
- onToggleReblog={handleToggleReblog}
- onToggleBookmark={handleToggleBookmark}
- onDelete={handleDeleteStatus}
- onReact={handleReact}
- onProfileClick={(item) => onProfileClick(item, account)}
- activeHandle={
- account?.handle ? formatHandle(account.handle, account.instanceUrl) : account?.instanceUrl ?? ""
- }
- activeAccountHandle={account?.handle ?? ""}
- activeAccountUrl={account?.url ?? null}
- account={account}
- api={services.api}
- showProfileImage={showProfileImage}
- showCustomEmojis={showCustomEmojis}
- showReactions={showReactions}
- disableActions
- />
- ))}
-
- ) : null}
- {notificationsLoadingMore ?
더 불러오는 중...
: null}
-
-
- >
- ) : null}
-
-
-
- {menuOpen ? (
- <>
-
-
-
-
-
-
-
-
-
-
- >
- ) : null}
-
-
-
-
- {!account ?
계정을 선택하면 타임라인을 불러옵니다.
: null}
- {account && timeline.items.length === 0 && !timeline.loading ? (
-
{emptyMessage}
- ) : null}
- {account && timeline.items.length > 0 ? (
-
- {timeline.items.map((status) => (
- onReply(item, account)}
- onStatusClick={(status) => onStatusClick(status, account)}
- onToggleFavourite={handleToggleFavourite}
- onToggleReblog={handleToggleReblog}
- onToggleBookmark={handleToggleBookmark}
- onDelete={handleDeleteStatus}
- onReact={handleReact}
- onProfileClick={(item) => onProfileClick(item, account)}
- activeHandle={
- account.handle ? formatHandle(account.handle, account.instanceUrl) : account.instanceUrl
- }
- activeAccountHandle={account.handle ?? ""}
- activeAccountUrl={account.url ?? null}
- account={account}
- api={services.api}
- showProfileImage={showProfileImage}
- showCustomEmojis={showCustomEmojis}
- showReactions={showReactions}
- disableActions={actionsDisabled}
- />
- ))}
-
- ) : null}
- {timeline.loadingMore ?
더 불러오는 중...
: null}
-
-
-
- );
-};
-
-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