diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0f84cd6..feb5152 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,15 +7,13 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: "beta-pages" cancel-in-progress: true jobs: - build: + build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout @@ -32,19 +30,9 @@ jobs: - name: Build run: bun run build - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + - name: Deploy to Cloudflare Pages (Beta) + uses: cloudflare/wrangler-action@v3 with: - path: ./dist - - deploy: - needs: build - if: github.event_name == 'push' - runs-on: ubuntu-latest - environment: - name: github-pages-beta - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages (Beta) - id: deployment - uses: actions/deploy-pages@v4 + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy ./dist --project-name ${{ secrets.CLOUDFLARE_PAGES_PROJECT_NAME_BETA }} --branch develop diff --git a/functions/api/preview.ts b/functions/api/preview.ts new file mode 100644 index 0000000..f385824 --- /dev/null +++ b/functions/api/preview.ts @@ -0,0 +1,213 @@ +type Env = Record; + +const MAX_RESPONSE_BYTES = 512 * 1024; +const REQUEST_TIMEOUT_MS = 5000; + +const textDecoder = new TextDecoder("utf-8"); + +const decodeHtmlEntities = (value: string): string => + value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); + +const extractMetaTagContent = (html: string, attribute: "property" | "name", key: string): string | null => { + const tagRegex = new RegExp(`]+${attribute}=["']${key}["'][^>]*>`, "i"); + const match = html.match(tagRegex); + if (!match) { + return null; + } + const contentMatch = match[0].match(/content=["']([^"']+)["']/i); + if (!contentMatch) { + return null; + } + return decodeHtmlEntities(contentMatch[1].trim()); +}; + +const extractTitle = (html: string): string | null => { + const match = html.match(/]*>([^<]*)<\/title>/i); + if (!match) { + return null; + } + const text = decodeHtmlEntities(match[1].trim()); + return text || null; +}; + +const toAbsoluteUrl = (value: string | null, baseUrl: string): string | null => { + if (!value) { + return null; + } + try { + return new URL(value, baseUrl).toString(); + } catch { + return null; + } +}; + +const isValidHttpUrl = (value: string): URL | null => { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + return url; + } catch { + return null; + } +}; + +const isIpAddress = (host: string): boolean => /^(\d{1,3}\.){3}\d{1,3}$/.test(host); + +const isPrivateIpv4 = (host: string): boolean => { + if (!isIpAddress(host)) { + return false; + } + const parts = host.split(".").map((item) => Number(item)); + if (parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) { + return false; + } + const [a, b] = parts; + if (a === 10) return true; + if (a === 127) return true; + if (a === 0) return true; + if (a === 169 && b === 254) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + return false; +}; + +const isPrivateIpv6 = (host: string): boolean => { + const normalized = host.toLowerCase(); + return ( + normalized === "::1" || + normalized.startsWith("fe80:") || + normalized.startsWith("fc") || + normalized.startsWith("fd") + ); +}; + +const isBlockedHostname = (hostname: string): boolean => { + const lower = hostname.toLowerCase(); + if (lower === "localhost" || lower.endsWith(".local")) { + return true; + } + if (isPrivateIpv4(lower) || isPrivateIpv6(lower)) { + return true; + } + return false; +}; + +const readResponseText = async (response: Response): Promise => { + if (!response.body) { + return response.text(); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value) { + total += value.length; + if (total > MAX_RESPONSE_BYTES) { + break; + } + chunks.push(value); + } + } + const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + return textDecoder.decode(combined); +}; + +const buildResponse = (body: Record, status = 200, cacheSeconds = 600): Response => { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": `public, max-age=${cacheSeconds}`, + "Access-Control-Allow-Origin": "*" + } + }); +}; + +export const onRequestGet = async (context: { request: Request } & { env?: Env }) => { + const requestUrl = new URL(context.request.url); + const urlParam = requestUrl.searchParams.get("url"); + if (!urlParam) { + return buildResponse({ error: "missing_url" }, 400, 60); + } + + const targetUrl = isValidHttpUrl(urlParam); + if (!targetUrl || isBlockedHostname(targetUrl.hostname)) { + return buildResponse({ error: "invalid_url" }, 400, 60); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(targetUrl.toString(), { + signal: controller.signal, + headers: { + "User-Agent": "DeckLinkPreview/1.0", + Accept: "text/html,application/xhtml+xml" + } + }); + + if (!response.ok) { + return buildResponse({ error: "fetch_failed", status: response.status }, 200, 60); + } + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("text/html")) { + return buildResponse({ error: "unsupported_content" }, 200, 300); + } + + const html = await readResponseText(response); + if (!html) { + return buildResponse({ error: "empty_body" }, 200, 60); + } + + const ogTitle = extractMetaTagContent(html, "property", "og:title"); + const ogDescription = extractMetaTagContent(html, "property", "og:description"); + const ogImageRaw = extractMetaTagContent(html, "property", "og:image"); + const ogUrl = extractMetaTagContent(html, "property", "og:url"); + const metaDescription = extractMetaTagContent(html, "name", "description"); + const title = ogTitle || extractTitle(html); + const description = ogDescription || metaDescription; + const image = toAbsoluteUrl(ogImageRaw, targetUrl.toString()); + const canonicalUrl = toAbsoluteUrl(ogUrl, targetUrl.toString()) ?? targetUrl.toString(); + + if (!title) { + return buildResponse({ error: "missing_title" }, 200, 300); + } + + return buildResponse( + { + url: canonicalUrl, + title, + description: description || null, + image: image || null + }, + 200, + 600 + ); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return buildResponse({ error: "timeout" }, 200, 60); + } + return buildResponse({ error: "fetch_failed" }, 200, 60); + } finally { + clearTimeout(timeout); + } +}; diff --git a/index.html b/index.html index 2b6df8c..cbfd991 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ diff --git a/src/App.tsx b/src/App.tsx index ec3ba50..3427198 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -135,6 +135,7 @@ export const App = () => { ); const [replyTarget, setReplyTarget] = useState(null); const [selectedStatus, setSelectedStatus] = useState(null); + const [selectedStatusThreadAccount, setSelectedStatusThreadAccount] = useState(null); const [selectedTimelineStatus, setSelectedTimelineStatus] = useState(null); const [profileTargets, setProfileTargets] = useState([]); const [statusModalZIndex, setStatusModalZIndex] = useState(null); @@ -494,14 +495,79 @@ export const App = () => { ); }, []); + const selectLeftmostTimelineAtY = useCallback( + (targetCenterY: number) => { + 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; + } + const sectionElement = sectionRefs.current.get(leftmostSectionId); + const statusElements = sectionElement?.querySelectorAll("[data-status-id]"); + let nextStatusId = items[0]?.id ?? null; + 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 - targetCenterY); + if (!closestMatch || distance < closestMatch.distance) { + closestMatch = { id: statusId, distance }; + } + } + if (closestMatch) { + nextStatusId = closestMatch.id; + } + } + if (!nextStatusId) { + return; + } + setSelectedTimelineStatus({ sectionId: leftmostSectionId, statusId: nextStatusId }); + }, + [sections] + ); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) { return; } + if (document.querySelector('[data-emoji-picker-open="true"]')) { + return; + } const hasOverlayBackdrop = document.querySelector( ".overlay-backdrop, .image-modal, .confirm-modal, .profile-modal, .status-modal, .settings-modal, .info-modal" ); + if (hasOverlayBackdrop) { + return; + } if (selectedStatus || settingsOpen || infoModal || mobileMenuOpen || mobileComposeOpen) { return; } @@ -826,8 +892,7 @@ export const App = () => { const handleStatusClick = (status: Status, columnAccount: Account | null) => { setSelectedStatus(status); setStatusModalZIndex(nextModalZIndexRef.current++); - // Status에 columnAccount 정보를 임시 저장 - (status as any).__columnAccount = columnAccount; + setSelectedStatusThreadAccount(columnAccount); }; const handleProfileOpen = useCallback((target: Status, columnAccount: Account | null) => { @@ -850,6 +915,7 @@ export const App = () => { const handleCloseStatusModal = () => { setSelectedStatus(null); setStatusModalZIndex(null); + setSelectedStatusThreadAccount(null); }; const handleReaction = useCallback( @@ -862,24 +928,25 @@ export const App = () => { setActionError("리액션은 미스키 계정에서만 사용할 수 있습니다."); return; } - if (status.myReaction && status.myReaction !== reaction.name) { + const target = status.reblog ?? status; + if (target.myReaction && target.myReaction !== reaction.name) { setActionError("이미 리액션을 남겼습니다. 먼저 취소해주세요."); return; } setActionError(null); - const isRemoving = status.myReaction === reaction.name; - const optimistic = buildOptimisticReactionStatus(status, reaction, isRemoving); + const isRemoving = target.myReaction === reaction.name; + const optimistic = buildOptimisticReactionStatus(target, reaction, isRemoving); updateStatusEverywhere(account.id, optimistic); try { - const updated = isRemoving - ? await services.api.deleteReaction(account, status.id) - : await services.api.createReaction(account, status.id, reaction.name); + const updated = isRemoving + ? await services.api.deleteReaction(account, target.id) + : await services.api.createReaction(account, target.id, reaction.name); if (!hasSameReactions(updated, optimistic)) { updateStatusEverywhere(account.id, updated); } } catch (err) { setActionError(err instanceof Error ? err.message : "리액션 처리에 실패했습니다."); - updateStatusEverywhere(account.id, status); + updateStatusEverywhere(account.id, target); } }, [services.api, updateStatusEverywhere] @@ -986,7 +1053,7 @@ export const App = () => {
- Deck logo + Deck 로고
-
- ) : null} - {account && emojiCategories.length === 0 ? ( -

사용할 수 있는 이모지가 없습니다.

- ) : null} - {account && emojiCategories.length > 0 ? ( - <> - {hasEmojiSearch ? ( -
-
- 검색 결과 - {emojiSearchResults.length} -
- {emojiSearchResults.length > 0 ? ( -
- {emojiSearchResults.map((emoji) => ( - - ))} + <> + + ) : null} {isSubmitting ? ( diff --git a/src/ui/components/PomodoroTimer.tsx b/src/ui/components/PomodoroTimer.tsx index 144112d..98ce46d 100644 --- a/src/ui/components/PomodoroTimer.tsx +++ b/src/ui/components/PomodoroTimer.tsx @@ -14,6 +14,9 @@ type PomodoroTimerProps = { longBreakMinutes?: number; onSessionTypeChange?: (type: SessionType) => void; onRunningChange?: (isRunning: boolean) => void; + isTimelineItemSelected?: boolean; + onRequestClearTimelineSelection?: () => void; + onRequestSelectTimelineAtY?: (targetCenterY: number) => void; }; // TOTAL_SESSIONS을 targetCycles에 따라 동적으로 계산 @@ -41,6 +44,9 @@ export const PomodoroTimer = ({ longBreakMinutes = 30, onSessionTypeChange, onRunningChange, + isTimelineItemSelected = false, + onRequestClearTimelineSelection, + onRequestSelectTimelineAtY, }: PomodoroTimerProps) => { const targetCycles = 4; // 고정된 4사이클 const focusDuration = focusMinutes * 60; @@ -123,6 +129,9 @@ export const PomodoroTimer = ({ return []; } }); + const [selectedTodoId, setSelectedTodoId] = useState(null); + const todoListRef = useRef(null); + const todoInputRef = useRef(null); const sessionInfo = useMemo(() => getSessionInfo(session), [session, getSessionInfo]); @@ -158,6 +167,25 @@ export const PomodoroTimer = ({ localStorage.setItem("textodon.pomodoro.todos", JSON.stringify(todoItems)); }, [todoItems]); + useEffect(() => { + if (!selectedTodoId) { + return; + } + if (!todoItems.some((item) => item.id === selectedTodoId)) { + setSelectedTodoId(null); + } + }, [selectedTodoId, todoItems]); + + useEffect(() => { + if (!isTimelineItemSelected) { + return; + } + if (selectedTodoId) { + setSelectedTodoId(null); + } + }, [isTimelineItemSelected, selectedTodoId]); + + const playNotificationSound = useCallback(() => { try { if (!audioContextRef.current) { @@ -213,6 +241,80 @@ export const PomodoroTimer = ({ } }, [focusDuration]); + useEffect(() => { + const isEditableElement = (element: Element | null) => { + if (!element) { + return false; + } + return ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + (element as HTMLElement).isContentEditable + ); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } + if (document.querySelector('[data-emoji-picker-open="true"]')) { + return; + } + const hasOverlayBackdrop = document.querySelector( + ".overlay-backdrop, .image-modal, .confirm-modal, .profile-modal, .status-modal, .settings-modal, .info-modal" + ); + if (hasOverlayBackdrop) { + return; + } + if (isEditableElement(document.activeElement)) { + return; + } + + const key = event.key.toLowerCase(); + if ( + key === "s" && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + handleStart(); + return; + } + + if ( + key === "x" && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + handleReset(); + return; + } + + if ( + key === "f" && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey + ) { + const input = todoInputRef.current; + if (!input || input.disabled) { + return; + } + event.preventDefault(); + input.focus(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleReset, handleStart]); + // 설정이 변경되면 현재 세션 시간 업데이트 useEffect(() => { const info = getSessionInfo(session); @@ -340,12 +442,136 @@ export const PomodoroTimer = ({ ); }, []); - const handleRemoveTodo = useCallback((id: string) => { - setTodoItems((prev) => prev.filter((item) => item.id !== id)); - }, []); + const handleRemoveTodo = useCallback( + (id: string) => { + setTodoItems((prev) => { + const index = prev.findIndex((item) => item.id === id); + if (index === -1) { + return prev; + } + if (selectedTodoId === id) { + const nextId = prev[index + 1]?.id ?? prev[index - 1]?.id ?? null; + setSelectedTodoId(nextId); + } + return prev.filter((item) => item.id !== id); + }); + }, + [selectedTodoId] + ); const displayedTodos = useMemo(() => todoItems, [todoItems]); + const selectTodo = useCallback( + (id: string) => { + setSelectedTodoId(id); + onRequestClearTimelineSelection?.(); + }, + [onRequestClearTimelineSelection] + ); + + const handleTodoKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const target = event.target; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ) { + return; + } + + const { key } = event; + const lowerKey = key.length === 1 ? key.toLowerCase() : key; + if (key === "Escape") { + if (selectedTodoId) { + event.preventDefault(); + setSelectedTodoId(null); + } + return; + } + + if (key === " " || key === "Spacebar") { + if (!selectedTodoId) { + return; + } + event.preventDefault(); + handleToggleTodo(selectedTodoId); + return; + } + + if (lowerKey === "d") { + if (!selectedTodoId) { + return; + } + event.preventDefault(); + handleRemoveTodo(selectedTodoId); + return; + } + + if (key === "ArrowRight") { + if (!selectedTodoId || !onRequestSelectTimelineAtY) { + return; + } + const currentItem = todoListRef.current?.querySelector( + `[data-todo-id="${selectedTodoId}"]` + ); + if (!currentItem) { + return; + } + const targetY = currentItem.getBoundingClientRect().top + currentItem.getBoundingClientRect().height / 2; + event.preventDefault(); + onRequestSelectTimelineAtY(targetY); + return; + } + if (key !== "ArrowUp" && key !== "ArrowDown") { + return; + } + if (!selectedTodoId) { + return; + } + const currentIndex = displayedTodos.findIndex((item) => item.id === selectedTodoId); + if (currentIndex === -1) { + return; + } + const nextIndex = key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; + if (key === "ArrowDown" && nextIndex >= displayedTodos.length) { + event.preventDefault(); + todoInputRef.current?.focus(); + return; + } + if (nextIndex < 0 || nextIndex >= displayedTodos.length) { + return; + } + event.preventDefault(); + selectTodo(displayedTodos[nextIndex].id); + }, + [displayedTodos, handleRemoveTodo, handleToggleTodo, onRequestSelectTimelineAtY, selectTodo, selectedTodoId] + ); + + const handleTodoInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + todoInputRef.current?.blur(); + return; + } + if (event.key !== "ArrowUp") { + return; + } + if (displayedTodos.length === 0) { + return; + } + event.preventDefault(); + const lastTodo = displayedTodos[displayedTodos.length - 1]; + if (!lastTodo) { + return; + } + selectTodo(lastTodo.id); + todoListRef.current?.focus(); + }, + [displayedTodos, selectTodo] + ); + return (
{isRunning ? "정지" : "시작"} @@ -382,6 +609,7 @@ export const PomodoroTimer = ({ type="button" className="pomodoro-button pomodoro-reset" onClick={handleReset} + title="리셋 (X)" > 리셋 @@ -390,11 +618,23 @@ export const PomodoroTimer = ({
{displayedTodos.length > 0 ? ( -
+
0 ? 0 : -1} + onKeyDownCapture={handleTodoKeyDown} + title="↑/↓ 이동 · Space 완료 · D 삭제 · → 타임라인 이동 · ESC 선택 해제" + > {displayedTodos.map((item) => (
{ + selectTodo(item.id); + todoListRef.current?.focus(); + }} > setTodoInput(event.target.value)} + onKeyDown={handleTodoInputKeyDown} + onFocus={() => setSelectedTodoId(null)} placeholder="할 일 추가" aria-label="뽀모도로 투두 입력" + title="할 일 추가 (F) · ↑ 목록 이동 · ESC 포커스 해제" /> + startResize(event, { horizontalFactor: 0, verticalFactor: -1 })} + /> + startResize(event, { horizontalFactor: -1, verticalFactor: -1 })} + /> + startResize(event, { horizontalFactor: 1, verticalFactor: 0 })} + /> + startResize(event, { horizontalFactor: 1, verticalFactor: -1 })} + /> + startResize(event, { horizontalFactor: 0, verticalFactor: 1 })} + /> + startResize(event, { horizontalFactor: 1, verticalFactor: 1 })} + /> + startResize(event, { horizontalFactor: -1, verticalFactor: 0 })} + /> + startResize(event, { horizontalFactor: -1, verticalFactor: 1 })} + /> +
+ ) : null} +
+ ); +}; + export const TimelineItem = ({ status, onReply, @@ -36,6 +292,7 @@ export const TimelineItem = ({ onProfileClick, onStatusClick, onSelect, + onUpdateStatus, isSelected = false, account, api, @@ -58,6 +315,7 @@ export const TimelineItem = ({ onProfileClick?: (status: Status) => void; onStatusClick?: (status: Status) => void; onSelect?: (statusId: string) => void; + onUpdateStatus?: (status: Status) => void; isSelected?: boolean; account: Account | null; api: MastodonApi; @@ -77,6 +335,7 @@ export const TimelineItem = ({ const [showContent, setShowContent] = useState(() => displayStatus.spoilerText.length === 0); const [menuOpen, setMenuOpen] = useState(false); const [favouriteState, setFavouriteState] = useState(false); + const [previewCard, setPreviewCard] = useState(displayStatus.card ?? null); const imageContainerRef = useRef(null); const imageRef = useRef(null); const menuRef = useRef(null); @@ -133,21 +392,29 @@ export const TimelineItem = ({ reset: resetImageZoom } = useImageZoom(imageContainerRef, imageRef); const attachments = displayStatus.mediaAttachments; - const activeImageUrl = activeImageIndex !== null ? attachments[activeImageIndex]?.url ?? null : null; + const imageAttachments = useMemo( + () => attachments.filter((item) => item.kind === "image"), + [attachments] + ); + const mediaAttachments = useMemo( + () => attachments.filter((item) => item.kind !== "image"), + [attachments] + ); + const activeImageUrl = activeImageIndex !== null ? imageAttachments[activeImageIndex]?.url ?? null : null; const goToPrevImage = useCallback(() => { - if (activeImageIndex === null || attachments.length <= 1) return; - const prevIndex = activeImageIndex === 0 ? attachments.length - 1 : activeImageIndex - 1; + if (activeImageIndex === null || imageAttachments.length <= 1) return; + const prevIndex = activeImageIndex === 0 ? imageAttachments.length - 1 : activeImageIndex - 1; setActiveImageIndex(prevIndex); resetImageZoom(); - }, [activeImageIndex, attachments.length, resetImageZoom]); + }, [activeImageIndex, imageAttachments.length, resetImageZoom]); const goToNextImage = useCallback(() => { - if (activeImageIndex === null || attachments.length <= 1) return; - const nextIndex = activeImageIndex === attachments.length - 1 ? 0 : activeImageIndex + 1; + if (activeImageIndex === null || imageAttachments.length <= 1) return; + const nextIndex = activeImageIndex === imageAttachments.length - 1 ? 0 : activeImageIndex + 1; setActiveImageIndex(nextIndex); resetImageZoom(); - }, [activeImageIndex, attachments.length, resetImageZoom]); + }, [activeImageIndex, imageAttachments.length, resetImageZoom]); useEffect(() => { if (activeImageIndex === null) return; @@ -169,7 +436,9 @@ export const TimelineItem = ({ return () => window.removeEventListener("keydown", handleKeyDown); }, [activeImageIndex, goToPrevImage, goToNextImage]); - const previewCard = displayStatus.card; + useEffect(() => { + setPreviewCard(displayStatus.card ?? null); + }, [displayStatus.card, displayStatus.id]); const displayHandle = useMemo(() => { if (displayStatus.accountHandle.includes("@")) { return displayStatus.accountHandle; @@ -550,6 +819,46 @@ export const TimelineItem = ({ }, [mentionMap] ); + const resolveMentionUrl = useCallback( + (handle: string) => { + const normalizedHandle = normalizeMentionHandle(handle); + if (!normalizedHandle) { + return null; + } + const mention = mentionMap.get(normalizedHandle) ?? null; + if (mention?.url) { + return mention.url; + } + if (!normalizedHandle.includes("@")) { + return null; + } + const [username, ...rest] = normalizedHandle.split("@"); + const host = rest.join("@"); + if (!username || !host) { + return null; + } + return `https://${host}/@${username}`; + }, + [mentionMap] + ); + const normalizedActiveMentionHandle = useMemo(() => { + const base = normalizeMentionHandle(activeAccountHandle); + if (!base) { + return ""; + } + if (base.includes("@")) { + return base; + } + if (!activeAccountUrl) { + return base; + } + try { + const host = new URL(activeAccountUrl).hostname; + return host ? `${base}@${host}` : base; + } catch { + return base; + } + }, [activeAccountHandle, activeAccountUrl]); const handleRichContentClick = useCallback( (event: React.MouseEvent) => { if (!onProfileClick || !(event.target instanceof Element)) { @@ -575,13 +884,43 @@ export const TimelineItem = ({ const normalizedHandle = normalizeMentionHandle(text); mention = normalizedHandle ? mentionMap.get(normalizedHandle) ?? null : null; } + if (!mention) { + const text = anchor.textContent ?? ""; + const normalizedHandle = normalizeMentionHandle(text); + let derivedHandle = normalizedHandle; + try { + const parsed = new URL(href); + const match = parsed.pathname.replace(/\/$/, "").match(/^\/@([^/]+)$/); + if (match?.[1]) { + const username = match[1]; + derivedHandle = derivedHandle?.includes("@") ? derivedHandle : `${username}@${parsed.hostname}`; + } + } catch { + /* noop */ + } + if (derivedHandle) { + mention = { + id: `mention-${displayStatus.id}-${derivedHandle}`, + displayName: derivedHandle, + handle: derivedHandle, + url: href + }; + } + } if (!mention?.id) { + if (!mention) { + return; + } + } + const normalizedMentionHandle = normalizeMentionHandle(mention.handle); + if (normalizedMentionHandle && normalizedMentionHandle === normalizedActiveMentionHandle) { + event.preventDefault(); return; } event.preventDefault(); onProfileClick(buildMentionStatus(mention)); }, - [buildMentionStatus, mentionMap, mentionUrlMap, onProfileClick] + [buildMentionStatus, mentionMap, mentionUrlMap, normalizedActiveMentionHandle, onProfileClick] ); const tokenizeWithEmojis = useCallback((text: string, emojiMap: Map) => { @@ -640,6 +979,21 @@ export const TimelineItem = ({ ); } + if (account?.platform === "misskey") { + const emojiMap = + showCustomEmojis && displayStatus.customEmojis.length > 0 + ? buildEmojiMap(displayStatus.customEmojis) + : undefined; + const markdownHtml = renderMarkdown(displayStatus.content, emojiMap, { mentionResolver: resolveMentionUrl }); + return ( +
+ ); + } + // Fallback to plain text with link detection const text = displayStatus.content; if (!showCustomEmojis || displayStatus.customEmojis.length === 0) { @@ -674,10 +1028,15 @@ export const TimelineItem = ({ return parts; }, [ buildEmojiMap, + account?.platform, displayStatus.content, displayStatus.customEmojis, - displayStatus.htmlContent, displayStatus.hasRichContent, + displayStatus.htmlContent, + handleMentionClick, + handleRichContentClick, + resolveMention, + resolveMentionUrl, showCustomEmojis, tokenizeWithEmojis ]); @@ -769,8 +1128,39 @@ export const TimelineItem = ({ actionsEnabled && showReactions && account?.platform === "misskey"; - const hasAttachmentButtons = showContent && attachments.length > 0; + const hasAttachmentButtons = showContent && imageAttachments.length > 0; const shouldRenderFooter = actionsEnabled || hasAttachmentButtons; + const renderMediaItem = useCallback((item: MediaAttachment) => { + const label = item.description ?? "첨부 미디어"; + if (item.kind === "audio") { + return ( +
+ +
+ ); + } + if (item.kind === "unknown") { + return ( + + ); + } + return ( +
+ +
+ ); + }, []); useEffect(() => { setShowContent(displayStatus.spoilerText.length === 0); @@ -787,6 +1177,80 @@ export const TimelineItem = ({ }; }, [activeImageUrl]); + const previewCandidate = useMemo( + () => (displayStatus.card ? null : extractFirstUrl(displayStatus.content)), + [displayStatus.card, displayStatus.content] + ); + + useEffect(() => { + if (!isPreviewEnabled() || previewCard || !previewCandidate || account?.platform !== "misskey") { + return; + } + if (previewCache.has(previewCandidate)) { + const cached = previewCache.get(previewCandidate) ?? null; + if (cached) { + setPreviewCard(cached); + } + return; + } + + let cancelled = false; + const controller = new AbortController(); + + const fetchPreview = async () => { + try { + const response = await fetch(`/api/preview?url=${encodeURIComponent(previewCandidate)}`, { + signal: controller.signal + }); + if (!response.ok) { + previewCache.set(previewCandidate, null); + return; + } + const data = (await response.json()) as + | { url?: string; title?: string; description?: string | null; image?: string | null; error?: string } + | undefined; + if (!data || data.error || !data.title || !data.url) { + previewCache.set(previewCandidate, null); + return; + } + const card: LinkCard = { + url: data.url, + title: data.title, + description: data.description ?? null, + image: data.image ?? null + }; + previewCache.set(previewCandidate, card); + if (cancelled) { + return; + } + setPreviewCard(card); + const updateTarget = (() => { + if (displayStatus.id === status.id) { + return { ...status, card }; + } + if (status.reblog && status.reblog.id === displayStatus.id) { + return { ...status, reblog: { ...status.reblog, card } }; + } + return null; + })(); + if (updateTarget && onUpdateStatus) { + onUpdateStatus(updateTarget); + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return; + } + previewCache.set(previewCandidate, null); + } + }; + + fetchPreview(); + return () => { + cancelled = true; + controller.abort(); + }; + }, [account?.platform, displayStatus.id, displayStatus.card, displayStatus.content, onUpdateStatus, previewCandidate, previewCard, status]); + const handleReactionSelect = useCallback( (reaction: ReactionInput) => { @@ -995,6 +1459,11 @@ export const TimelineItem = ({
) : null} + {mediaAttachments.length > 0 ? ( +
+ {mediaAttachments.map((item) => renderMediaItem(item))} +
+ ) : null} ) : null}
) : null} {showContent - ? attachments.map((item, index) => ( + ? imageAttachments.map((item, index) => ( )) : null} @@ -1191,7 +1660,7 @@ export const TimelineItem = ({ > 닫기 - {attachments.length > 1 ? ( + {imageAttachments.length > 1 ? ( ) : null} - {attachments.length > 1 ? ( + {imageAttachments.length > 1 ? (
@@ -1239,10 +1708,3 @@ export const TimelineItem = ({ ); }; - - - - - - - diff --git a/src/ui/components/TimelineSection.tsx b/src/ui/components/TimelineSection.tsx index 417fada..41b3a06 100644 --- a/src/ui/components/TimelineSection.tsx +++ b/src/ui/components/TimelineSection.tsx @@ -739,6 +739,20 @@ export const TimelineSection = ({ ] ); + useEffect(() => { + if (!timelineMenuOpen && !notificationsOpen && !menuOpen) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + const handled = handleTimelineShortcuts(event); + if (handled) { + event.stopPropagation(); + } + }; + window.addEventListener("keydown", onKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); + }, [handleTimelineShortcuts, menuOpen, notificationsOpen, timelineMenuOpen]); + useEffect(() => { registerTimelineShortcutHandler(section.id, handleTimelineShortcuts); return () => registerTimelineShortcutHandler(section.id, null); @@ -869,9 +883,9 @@ export const TimelineSection = ({ {notificationItems.length > 0 ? (
{notificationItems.map((status, statusIndex) => ( - onReply(item, account)} onStatusClick={(currentStatus) => onStatusClick(currentStatus, account)} onToggleFavourite={handleToggleFavourite} @@ -1020,9 +1034,10 @@ export const TimelineSection = ({ status={status} onReply={(item) => onReply(item, account)} onStatusClick={(currentStatus) => onStatusClick(currentStatus, account)} - onSelect={(statusId) => onSelectStatus(section.id, statusId)} - isSelected={selectedStatusId === status.id} - onToggleFavourite={handleToggleFavourite} + onSelect={(statusId) => onSelectStatus(section.id, statusId)} + isSelected={selectedStatusId === status.id} + onUpdateStatus={timeline.updateItem} + onToggleFavourite={handleToggleFavourite} onToggleReblog={handleToggleReblog} onToggleBookmark={handleToggleBookmark} onDelete={handleDeleteStatus} diff --git a/src/ui/content/shortcuts.ts b/src/ui/content/shortcuts.ts index c1608b4..a82ee42 100644 --- a/src/ui/content/shortcuts.ts +++ b/src/ui/content/shortcuts.ts @@ -55,6 +55,24 @@ export const shortcutSections: Array<{ { keys: "ESC", description: "추천 닫기" } ] }, + { + title: "이모지 패널/리액션", + note: "이모지 선택 팝오버가 열려 있을 때만 동작합니다.", + items: [ + { keys: "↑ / ↓", description: "이모지/카테고리 이동" }, + { keys: "← / →", description: "카테고리 접기/펼치기" }, + { keys: "Enter", description: "선택된 이모지 입력/리액션" }, + { keys: "ESC", description: "이모지 선택 닫기" } + ] + }, + { + title: "뽀모도로 타이머", + items: [ + { keys: "S", description: "뽀모도로 타이머 시작/정지" }, + { keys: "X", description: "뽀모도로 타이머 리셋" }, + { keys: "F", description: "할 일 추가 입력으로 이동" } + ] + }, { title: "이미지 뷰어", items: [ diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 022af1d..37360b3 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -921,6 +921,8 @@ } .compose-emoji-panel { + position: relative; + z-index: 20; margin-top: 12px; border: 1px solid var(--color-emoji-panel-border); border-radius: 12px; @@ -1910,6 +1912,155 @@ button.ghost { display: block; } +.status-media { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.status-media-item { + width: 100%; +} + +.status-media-video { + width: 100%; +} + +.status-media-video video { + width: 100%; + max-width: 100%; + border-radius: 12px; + background: var(--color-attachment-thumb-bg); + display: block; +} + +.status-media-video video.is-floating { + position: fixed; + right: 16px; + bottom: 16px; + width: min(320px, 70vw); + height: auto; + z-index: 60; + box-shadow: var(--shadow-section-menu); + border: 1px solid var(--color-attachment-thumb-border); +} + +.status-media-resize-overlay { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 61; + pointer-events: none; +} + +.status-media-floating-close { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + background: rgba(0, 0, 0, 0.6); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + pointer-events: auto; +} + +.status-media-resize-edge { + position: absolute; + pointer-events: auto; + touch-action: none; +} + +.status-media-resize-corner { + position: absolute; + pointer-events: auto; + touch-action: none; + width: 16px; + height: 16px; +} + +.status-media-resize-edge.edge-top { + top: -6px; + left: 0; + right: 0; + height: 12px; + cursor: ns-resize; +} + +.status-media-resize-edge.edge-right { + top: 0; + right: -6px; + bottom: 0; + width: 12px; + cursor: ew-resize; +} + +.status-media-resize-edge.edge-bottom { + left: 0; + right: 0; + bottom: -6px; + height: 12px; + cursor: ns-resize; +} + +.status-media-resize-edge.edge-left { + top: 0; + left: -6px; + bottom: 0; + width: 12px; + cursor: ew-resize; +} + +.status-media-resize-corner.corner-top-left { + top: -8px; + left: -8px; + cursor: nwse-resize; +} + +.status-media-resize-corner.corner-top-right { + top: -8px; + right: -8px; + cursor: nesw-resize; +} + +.status-media-resize-corner.corner-bottom-right { + right: -8px; + bottom: -8px; + cursor: nwse-resize; +} + +.status-media-resize-corner.corner-bottom-left { + left: -8px; + bottom: -8px; + cursor: nesw-resize; +} + +.status-media-item video, +.status-media-item audio { + width: 100%; + max-width: 100%; + border-radius: 12px; + background: var(--color-attachment-thumb-bg); + display: block; +} + +.status-media-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 14px; + border-radius: 10px; + background: var(--color-attachment-thumb-bg); + border: 1px solid var(--color-attachment-thumb-border); + color: inherit; + text-decoration: none; + font-size: 13px; + font-weight: 500; +} + .image-modal { position: fixed; inset: 0; @@ -2951,6 +3102,10 @@ button.ghost { gap: 8px; } +.pomodoro-todo-list:focus { + outline: none; +} + .pomodoro-todo-item { display: flex; align-items: center; @@ -2964,6 +3119,11 @@ button.ghost { font-size: 13px; } +.pomodoro-todo-item.is-selected { + border-color: transparent; + box-shadow: 0 0 0 2px var(--color-action-active-bg) inset; +} + .pomodoro-todo-item.is-completed .pomodoro-todo-text { color: var(--color-text-secondary); text-decoration: line-through; diff --git a/src/ui/utils/markdown.test.ts b/src/ui/utils/markdown.test.ts index edc6887..282f653 100644 --- a/src/ui/utils/markdown.test.ts +++ b/src/ui/utils/markdown.test.ts @@ -41,4 +41,32 @@ describe("renderMarkdown", () => { '

hi :wave: :wave:

' ); }); + + it("linkifies bare URLs", () => { + const input = "visit https://example.com/test."; + const output = renderMarkdown(input); + + expect(output).toBe( + '

visit https://example.com/test.

' + ); + }); + + it("does not double-link markdown links", () => { + const input = "[link](https://example.com) https://example.com"; + const output = renderMarkdown(input); + + expect(output).toBe( + '

link https://example.com

' + ); + }); + + it("renders emojis inside markdown link labels", () => { + const input = "[go :wave:](https://example.com)"; + const emojiMap = new Map([["wave", "https://example.com/wave.png"]]); + const output = renderMarkdown(input, emojiMap); + + expect(output).toBe( + '

go :wave:

' + ); + }); }); diff --git a/src/ui/utils/markdown.ts b/src/ui/utils/markdown.ts index 1a357cd..f36a07c 100644 --- a/src/ui/utils/markdown.ts +++ b/src/ui/utils/markdown.ts @@ -17,6 +17,8 @@ const isSafeUrl = (url: string): boolean => { return !hasScheme || /^https?:/i.test(trimmed); }; +const normalizeMentionHandle = (handle: string): string => handle.replace(/^@/, "").trim().toLowerCase(); + const renderImageTag = (alt: string, url: string): string => { if (!isSafeUrl(url)) { return escapeHtml(`![${alt}](${url})`); @@ -35,7 +37,28 @@ const renderEmojiTag = (shortcode: string, url: string): string => { return `${safeAlt}`; }; -const formatInline = (text: string, emojiMap?: Map): string => { +// Linkify plain URLs while excluding trailing punctuation. +const linkifyBareUrls = (text: string): string => { + return text.replace(/https?:\/\/[^\s<]+[^\s<\])"'.,;:!?]/g, (match) => { + if (!isSafeUrl(match)) { + return match; + } + const safeUrl = escapeAttr(match); + return `${match}`; + }); +}; + +// Tokenize inline elements first, then escape/format once to avoid double parsing. +const formatInline = ( + text: string, + emojiMap?: Map, + options: { + linkify?: boolean; + parseLinks?: boolean; + parseMentions?: boolean; + mentionResolver?: (handle: string) => string | null; + } = {} +): string => { const codeSpans: string[] = []; let tokenized = text.replace(/`([^`]+)`/g, (_match, code) => { const safeCode = escapeHtml(code); @@ -48,6 +71,37 @@ const formatInline = (text: string, emojiMap?: Map): string => { images.push(imageTag); return `\u0000${images.length - 1}\u0000`; }); + const links: string[] = []; + if (options.parseLinks !== false) { + tokenized = tokenized.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => { + // Parse link labels once; avoid nested markdown/link parsing and linkify here. + const safeLabel = formatInline(label, emojiMap, { + linkify: false, + parseLinks: false, + parseMentions: false + }); + const safeUrl = escapeAttr(url); + links.push(`${safeLabel}`); + return `\u0003${links.length - 1}\u0003`; + }); + } + const mentions: string[] = []; + if (options.parseMentions !== false && options.mentionResolver) { + tokenized = tokenized.replace( + /@[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?=[^\w@]|$)/g, + (match) => { + const normalized = normalizeMentionHandle(match); + const url = options.mentionResolver?.(normalized); + if (!url) { + return match; + } + const safeUrl = escapeAttr(url); + const safeLabel = escapeHtml(match); + mentions.push(`${safeLabel}`); + return `\u0004${mentions.length - 1}\u0004`; + } + ); + } const emojis: string[] = []; if (emojiMap && emojiMap.size > 0) { tokenized = tokenized.replace(/:([a-zA-Z0-9_]+):/g, (_match, shortcode) => { @@ -63,17 +117,22 @@ const formatInline = (text: string, emojiMap?: Map): string => { let out = escapeHtml(tokenized); out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); out = out.replace(/\*([^*]+)\*/g, "$1"); - out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => { - const safeUrl = escapeAttr(url); - return `${label}`; - }); + if (options.linkify !== false) { + out = linkifyBareUrls(out); + } out = out.replace(/\u0000(\d+)\u0000/g, (_match, index) => images[Number(index)] ?? ""); out = out.replace(/\u0002(\d+)\u0002/g, (_match, index) => emojis[Number(index)] ?? ""); out = out.replace(/\u0001(\d+)\u0001/g, (_match, index) => codeSpans[Number(index)] ?? ""); + out = out.replace(/\u0003(\d+)\u0003/g, (_match, index) => links[Number(index)] ?? ""); + out = out.replace(/\u0004(\d+)\u0004/g, (_match, index) => mentions[Number(index)] ?? ""); return out; }; -export const renderMarkdown = (markdown: string, emojiMap?: Map): string => { +export const renderMarkdown = ( + markdown: string, + emojiMap?: Map, + options?: { mentionResolver?: (handle: string) => string | null } +): string => { const lines = markdown.split(/\r?\n/); const blocks: string[] = []; let inCode = false; @@ -84,14 +143,18 @@ export const renderMarkdown = (markdown: string, emojiMap?: Map) const flushParagraph = () => { if (paragraphBuffer.length === 0) return; - const content = paragraphBuffer.map((line) => formatInline(line, emojiMap)).join("
"); + const content = paragraphBuffer + .map((line) => formatInline(line, emojiMap, { mentionResolver: options?.mentionResolver })) + .join("
"); blocks.push(`

${content}

`); paragraphBuffer = []; }; const flushList = () => { if (listBuffer.length === 0) return; - const items = listBuffer.map((item) => `
  • ${formatInline(item, emojiMap)}
  • `).join(""); + const items = listBuffer + .map((item) => `
  • ${formatInline(item, emojiMap, { mentionResolver: options?.mentionResolver })}
  • `) + .join(""); blocks.push(`
      ${items}
    `); listBuffer = []; }; @@ -141,7 +204,9 @@ export const renderMarkdown = (markdown: string, emojiMap?: Map) flushParagraph(); flushList(); const level = headingMatch[1].length; - blocks.push(`${formatInline(headingMatch[2], emojiMap)}`); + blocks.push( + `${formatInline(headingMatch[2], emojiMap, { mentionResolver: options?.mentionResolver })}` + ); continue; }