From d6b7ecc8fd2c0ad92519c0be22de6b6f6752f035 Mon Sep 17 00:00:00 2001 From: talksik Date: Sat, 21 Mar 2026 12:46:23 -0700 Subject: [PATCH] feat: support sending and preview links --- js/src/components/link-preview-card.tsx | 104 +++++++++++++++ js/src/electron.d.ts | 22 +++- js/src/features/compose/text-compose-step.tsx | 122 +++++++++++++----- .../features/particles/text-particle-view.tsx | 87 ++++++++++--- js/src/hooks/use-link-metadata.ts | 19 +++ js/src/lib/link-metadata.ts | 14 ++ js/src/main.ts | 118 ++++++++++++++++- js/src/preload.ts | 5 + 8 files changed, 431 insertions(+), 60 deletions(-) create mode 100644 js/src/components/link-preview-card.tsx create mode 100644 js/src/hooks/use-link-metadata.ts create mode 100644 js/src/lib/link-metadata.ts diff --git a/js/src/components/link-preview-card.tsx b/js/src/components/link-preview-card.tsx new file mode 100644 index 0000000..f6ef363 --- /dev/null +++ b/js/src/components/link-preview-card.tsx @@ -0,0 +1,104 @@ +import { Copy, ExternalLink, Globe } from "lucide-react"; +import type { LinkMetadata } from "@/lib/link-metadata"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface LinkPreviewCardProps { + metadata: LinkMetadata; + compact?: boolean; +} + +export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) { + const handleOpen = (e: React.MouseEvent) => { + e.stopPropagation(); + window.electronLink.openExternal(metadata.url); + }; + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(metadata.url); + }; + + return ( +
e.stopPropagation()} + > + {metadata.image && ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} +
+
+ {metadata.favicon ? ( + { + (e.target as HTMLImageElement).replaceWith( + document.createElement("span"), + ); + }} + /> + ) : ( + + )} + {metadata.domain} +
+ {metadata.title && ( +

+ {metadata.title} +

+ )} + {metadata.description && ( +

+ {metadata.description} +

+ )} + {!compact && ( +
+ + +
+ )} +
+
+ ); +} + +export function LinkPreviewCardSkeleton() { + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/js/src/electron.d.ts b/js/src/electron.d.ts index 7511eef..6da0a30 100644 --- a/js/src/electron.d.ts +++ b/js/src/electron.d.ts @@ -1,7 +1,17 @@ -interface Window { - electronWindow: { - minimize: () => void; - maximize: () => void; - close: () => void; - }; +import type { LinkMetadata } from './lib/link-metadata'; + +declare global { + interface Window { + electronWindow: { + minimize: () => void; + maximize: () => void; + close: () => void; + }; + electronLink: { + fetchMetadata: (url: string) => Promise; + openExternal: (url: string) => Promise; + }; + } } + +export {}; diff --git a/js/src/features/compose/text-compose-step.tsx b/js/src/features/compose/text-compose-step.tsx index c3fcfe8..27144b8 100644 --- a/js/src/features/compose/text-compose-step.tsx +++ b/js/src/features/compose/text-compose-step.tsx @@ -1,5 +1,10 @@ -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef, useCallback, useState } from "react"; import { cn } from "@/lib/utils"; +import { useFirstLinkMetadata } from "@/hooks/use-link-metadata"; +import { + LinkPreviewCard, + LinkPreviewCardSkeleton, +} from "@/components/link-preview-card"; interface TextComposeStepProps { textContent: string; @@ -8,11 +13,12 @@ interface TextComposeStepProps { onCancel: () => void; } -function getTextStyle(length: number) { - if (length < 50) return { size: "text-5xl", weight: "font-semibold" }; - if (length < 150) return { size: "text-3xl", weight: "font-semibold" }; - if (length < 300) return { size: "text-2xl", weight: "font-normal" }; - return { size: "text-lg", weight: "font-normal" }; +const IMMERSIVE_CHAR_LIMIT = 120; + +function getImmersiveTextStyle(length: number) { + if (length < 30) return { size: "text-5xl", weight: "font-semibold" }; + if (length < 70) return { size: "text-3xl", weight: "font-semibold" }; + return { size: "text-2xl", weight: "font-normal" }; } export function TextComposeStep({ @@ -23,6 +29,14 @@ export function TextComposeStep({ }: TextComposeStepProps) { const textareaRef = useRef(null); + // Debounce URL detection to avoid fetching on every keystroke + const [debouncedText, setDebouncedText] = useState(textContent); + useEffect(() => { + const t = setTimeout(() => setDebouncedText(textContent), 500); + return () => clearTimeout(t); + }, [textContent]); + const { data: metadata, isLoading, url: firstUrl } = useFirstLinkMetadata(debouncedText); + useEffect(() => { textareaRef.current?.focus(); }, []); @@ -40,37 +54,79 @@ export function TextComposeStep({ [onCancel, onAdvance, textContent], ); - const style = getTextStyle(textContent.length); + const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT; + const showPreview = firstUrl && (isLoading || metadata); + + const linkPreview = ( + <> + {firstUrl && isLoading && } + {firstUrl && metadata && } + + ); + + const keyboardHints = ( +
+ + + Esc + {" "} + cancel + + + + ⌘+Enter + {" "} + next + +
+ ); + + if (immersive) { + const style = getImmersiveTextStyle(textContent.length); + return ( +
+
+