diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx index 5256a84..012c83f 100644 --- a/apps/web/src/modules/mesh/topic-chat-panel.tsx +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -2,9 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Badge } from "@turbostarter/ui-web/badge"; import { Button } from "@turbostarter/ui-web/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card"; const POLL_INTERVAL_MS = 5000; @@ -71,6 +69,16 @@ function fmtTime(iso: string): string { } } +function fmtRelative(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`; + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`; + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`; + return new Date(iso).toLocaleDateString(); +} + +const monoStyle = { fontFamily: "var(--cm-font-mono)" } as const; + export function TopicChatPanel({ topicName, meshSlug, @@ -81,6 +89,8 @@ export function TopicChatPanel({ const [draft, setDraft] = useState(""); const [error, setError] = useState(null); const [sending, setSending] = useState(false); + const [isFetching, setIsFetching] = useState(false); + const [lastPollAt, setLastPollAt] = useState(null); const scrollRef = useRef(null); const headers = useMemo( @@ -92,6 +102,7 @@ export function TopicChatPanel({ ); const refresh = useCallback(async () => { + setIsFetching(true); try { const res = await fetch( `/api/v1/topics/${encodeURIComponent(topicName)}/messages?limit=100`, @@ -104,8 +115,11 @@ export function TopicChatPanel({ const json = (await res.json()) as { messages: TopicMessage[] }; setMessages(json.messages.slice().reverse()); setError(null); + setLastPollAt(Date.now()); } catch (e) { setError((e as Error).message); + } finally { + setIsFetching(false); } }, [headers, topicName]); @@ -148,56 +162,83 @@ export function TopicChatPanel({ } }; + const secondsSincePoll = lastPollAt + ? Math.max(0, Math.floor((Date.now() - lastPollAt) / 1000)) + : null; + return ( - - - - # - {topicName} - -
- - {meshSlug} - - - key expires {fmtTime(apiKeyExpiresAt)} +
+ {/* Header — mono strip, clay-pulse dot, metadata right */} +
+
+ + + #{topicName}
- + + {messages.length} msg ·{" "} + {isFetching + ? "polling…" + : `${secondsSincePoll ?? "—"}s ago`} + +
- + {/* Message stream */} +
{messages.length === 0 ? ( -

- No messages yet. Be the first. +

+ no envelopes on this topic yet

) : ( -
    +
      {messages.map((m) => ( -
    1. -
      - +
    2. +
      + {m.senderName || m.senderPubkey.slice(0, 8)} - - {m.senderPubkey.slice(0, 6)}… + + {m.senderPubkey.slice(0, 8)} + + + {fmtTime(m.createdAt)} - {fmtTime(m.createdAt)}
      -

      +

      {decodeIncoming(m.ciphertext)}

    3. ))}
    )} - +
-
+ {/* Compose */} +
{error ? ( -

{error}

+

+ error · {error} +

) : null}
setDraft(e.target.value)} - placeholder={`Message #${topicName}…`} + placeholder={`message #${topicName}…`} rows={1} - className="flex-1 resize-none rounded-md border bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + className="flex-1 resize-none rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)] focus:border-[var(--cm-border-hover)] focus:outline-none" onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -220,10 +261,26 @@ export function TopicChatPanel({ }} />
- + + {/* Status footer — 9px mono, matches peer-graph + state-timeline footers */} +
+ + + mesh · {meshSlug} + + polling every {POLL_INTERVAL_MS / 1000}s + key valid until {fmtTime(apiKeyExpiresAt)} + + v0.2.0 · plaintext base64 · per-topic crypto in v0.3.0 + +
+
); }