diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 186ed55..9800e38 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -41,7 +41,7 @@ Legend: 32. Security - `DONE` (sessions + revoke + 2FA + access-session visibility; integration tests cover single-session revoke, revoke-all invalidation/force-disconnect, 2FA setup guard, recovery-code normalization/lifecycle, and disable-2FA cleanup; web auth supports recovery-code login; settings provides recovery-code warning/copy/download) 33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted) 34. Sync - `DONE` (cross-device via backend state + realtime; reconciliation for loaded chats/messages; `chat_updated` covers profile/membership/delete-for-self/archive/unarchive/pin/unpin/mute/clear and create-chat fanout to members; full-chat delete emits `chat_deleted`; web Chat Info also has 15s polling fallback to self-heal missed realtime updates; integration tests cover user-scoped archive/pin, member-side visibility after create, and user-scoped clear behavior; chat list and migration `0026_deduplicate_saved_chats` handle historical duplicate Saved Messages) -35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic) +35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic; web text messages now render lightweight inline link cards (domain + URL) for first HTTP(S) link) ## Current Focus beyond P1 diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 074f505..5c238a3 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -1264,7 +1264,23 @@ function renderMessageContent( if (!text) { return

[empty]

; } - return

; + const firstUrl = extractFirstUrl(text); + return ( +

+

+ {firstUrl ? ( + +

{extractDomain(firstUrl)}

+

{truncateLink(firstUrl, 88)}

+
+ ) : null} +
+ ); } function renderStatus(status: string | undefined): string { @@ -1295,6 +1311,38 @@ function isStickerOrGifMedia(url: string): boolean { return /\.gif($|\?)/.test(value); } +function extractFirstUrl(text: string): string | null { + const match = text.match(/\bhttps?:\/\/[^\s<>"')]+/i); + if (!match) { + return null; + } + const candidate = match[0].trim(); + try { + const parsed = new URL(candidate); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return parsed.toString(); + } + } catch { + return null; + } + return null; +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return "Link"; + } +} + +function truncateLink(url: string, limit: number): string { + if (url.length <= limit) { + return url; + } + return `${url.slice(0, Math.max(0, limit - 1))}…`; +} + function chatLabel(chat: { display_title?: string | null; title: string | null; type: "private" | "group" | "channel"; is_saved?: boolean }): string { if (chat.display_title?.trim()) return chat.display_title; if (chat.title?.trim()) return chat.title;