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.<email>).
Auto-marked read when a thread is opened. Pure client-side; no extra EA2
writes beyond the existing listMessages calls.
This commit is contained in:
2026-06-14 14:58:21 +04:00
parent 15ab8ededa
commit 0266c77875
2 changed files with 105 additions and 11 deletions
+17
View File
@@ -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);
}
+83 -6
View File
@@ -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<string, string> {
try { return JSON.parse(localStorage.getItem(READ_KEY(email)) || "{}") || {}; } catch { return {}; }
}
function saveReadMap(email: string, map: Record<string, string>) {
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<ChatThread[]>([]);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [previews, setPreviews] = useState<Record<string, ChatMessage | null>>({});
const [readMap, setReadMap] = useState<Record<string, string>>(() => 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<string, ChatMessage | null> = {};
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() {
</div>
</div>
<div className="chat-thread-list">
{unreadCount > 0 && (
<div className="chat-unread-summary">{unreadCount} unread thread{unreadCount === 1 ? "" : "s"}</div>
)}
{loadingThreads && <div className="chat-empty">Loading threads</div>}
{!loadingThreads && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
{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 (
<button
key={t._key}
className={`chat-thread-row ${t._key === activeKey ? "active" : ""}`}
className={`chat-thread-row ${t._key === activeKey ? "active" : ""} ${unread ? "unread" : ""}`}
onClick={() => setActiveKey(t._key)}
>
<div className="chat-thread-name">{t.display_name}</div>
<div className="chat-thread-meta">{t.description}</div>
<div className="chat-thread-row-head">
<span className="chat-thread-name">{t.display_name}</span>
{preview && <span className="chat-thread-time">{relativeTime(preview.created_at)}</span>}
</div>
<div className="chat-thread-meta">{previewBody}</div>
{unread && <span className="chat-unread-dot" aria-label="unread" />}
</button>
))}
);
})}
</div>
</aside>