From 0266c77875627f402a461b081dacdea4729af604 Mon Sep 17 00:00:00 2001 From: shad Date: Sun, 14 Jun 2026 14:58:21 +0400 Subject: [PATCH] feat(chat): unread badges, last-message previews, relative timestamps Sidebar now shows: - aggregate unread count banner at top - per-thread last-message preview (You: prefix when self-sent) - relative timestamp on each thread (just now / Xm ago / Xh ago / Xd ago) - amber unread dot + bold name for threads with new messages from others Read state stored in localStorage per-user (fm.canvas.chat.read.). Auto-marked read when a thread is opened. Pure client-side; no extra EA2 writes beyond the existing listMessages calls. --- src/index.css | 17 ++++++++ src/scenes/Chat.tsx | 99 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/index.css b/src/index.css index 3154be3..cad2113 100644 --- a/src/index.css +++ b/src/index.css @@ -2009,3 +2009,20 @@ select.studio-input { background: var(--bp-paper); } .hub-queue-row.active { background: color-mix(in srgb, var(--bp-amber) 30%, var(--bp-paper)); border-color: var(--bp-navy); } .hub-queue-name { flex: 1; } @media (max-width: 900px) { .hub-split { grid-template-columns: 1fr; } } + +.chat-unread-summary { + padding: 8px 12px; + background: color-mix(in srgb, var(--bp-amber) 25%, var(--bp-paper)); + border-bottom: 1px solid var(--bp-navy); + font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; + color: var(--bp-navy); text-transform: uppercase; +} +.chat-thread-row { position: relative; } +.chat-thread-row.unread .chat-thread-name { font-weight: 700; } +.chat-thread-row-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } +.chat-thread-time { font-family: var(--bp-mono); font-size: 9px; color: var(--bp-muted); flex-shrink: 0; } +.chat-unread-dot { + position: absolute; right: 8px; top: 8px; + width: 6px; height: 6px; border-radius: 50%; + background: var(--bp-amber); +} diff --git a/src/scenes/Chat.tsx b/src/scenes/Chat.tsx index 2943df0..39f88ed 100644 --- a/src/scenes/Chat.tsx +++ b/src/scenes/Chat.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useApp } from "../state/store"; import { chatApi, type ChatThread, type ChatMessage } from "../lib/chatApi"; import { Bot } from "../components/icons"; @@ -14,6 +14,26 @@ function personaLabel(email: string): string { return PERSONA_DIRECTORY.find((p) => p.email === email)?.label || email; } +const READ_KEY = (email: string) => `fm.canvas.chat.read.${email}`; + +function loadReadMap(email: string): Record { + try { return JSON.parse(localStorage.getItem(READ_KEY(email)) || "{}") || {}; } catch { return {}; } +} +function saveReadMap(email: string, map: Record) { + try { localStorage.setItem(READ_KEY(email), JSON.stringify(map)); } catch { /* ignore */ } +} + +function relativeTime(iso?: string): string { + if (!iso) return ""; + const t = Date.parse(iso); + if (!Number.isFinite(t)) return ""; + const diff = Date.now() - t; + if (diff < 60_000) return "just now"; + if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / 86_400_000)}d ago`; +} + export default function Chat() { const userEmail = useApp((s) => s.userEmail); const pushToast = useApp((s) => s.pushToast); @@ -22,6 +42,8 @@ export default function Chat() { const [threads, setThreads] = useState([]); const [activeKey, setActiveKey] = useState(null); const [messages, setMessages] = useState([]); + const [previews, setPreviews] = useState>({}); + const [readMap, setReadMap] = useState>(() => loadReadMap(me)); const [draft, setDraft] = useState(""); const [loadingThreads, setLoadingThreads] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false); @@ -56,6 +78,14 @@ export default function Chat() { .then((rows) => { if (cancelled) return; setMessages(rows); + const newest = rows[rows.length - 1]; + if (newest) { + setReadMap((prev) => { + const next = { ...prev, [activeKey]: newest.created_at }; + saveReadMap(me, next); + return next; + }); + } setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }), 50); }) .catch((err) => pushToast("err", `Messages failed: ${err.message}`)) @@ -65,6 +95,39 @@ export default function Chat() { }; }, [activeKey]); + useEffect(() => { + if (threads.length === 0) return; + let cancelled = false; + Promise.all( + threads.map(async (t) => { + try { + const rows = await chatApi.listMessages(t._key); + return [t._key, rows[rows.length - 1] || null] as const; + } catch { + return [t._key, null] as const; + } + }) + ).then((pairs) => { + if (cancelled) return; + const map: Record = {}; + for (const [k, v] of pairs) map[k] = v; + setPreviews(map); + }); + return () => { cancelled = true; }; + }, [threads]); + + const unreadCount = useMemo(() => { + let n = 0; + for (const t of threads) { + const last = previews[t._key]; + if (!last) continue; + if (last.author_email === me) continue; + const readAt = readMap[t._key]; + if (!readAt || last.created_at > readAt) n++; + } + return n; + }, [threads, previews, readMap, me]); + const handleNewThread = async () => { if (!recipient || recipient === me) { pushToast("err", "Pick a recipient that's not yourself"); @@ -130,18 +193,32 @@ export default function Chat() {
+ {unreadCount > 0 && ( +
{unreadCount} unread thread{unreadCount === 1 ? "" : "s"}
+ )} {loadingThreads &&
Loading threads…
} {!loadingThreads && threads.length === 0 &&
No conversations yet. Start one above.
} - {threads.map((t) => ( - - ))} + {threads.map((t) => { + const preview = previews[t._key]; + const lastFromOther = preview && preview.author_email !== me; + const readAt = readMap[t._key]; + const unread = lastFromOther && (!readAt || preview!.created_at > readAt); + const previewBody = preview ? `${preview.author_email === me ? "You: " : ""}${preview.body}` : t.description; + return ( + + ); + })}