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 (
+
+ );
+ }
return (