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:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user