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-row.active { background: color-mix(in srgb, var(--bp-amber) 30%, var(--bp-paper)); border-color: var(--bp-navy); }
|
||||||
.hub-queue-name { flex: 1; }
|
.hub-queue-name { flex: 1; }
|
||||||
@media (max-width: 900px) { .hub-split { grid-template-columns: 1fr; } }
|
@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);
|
||||||
|
}
|
||||||
|
|||||||
+88
-11
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useApp } from "../state/store";
|
import { useApp } from "../state/store";
|
||||||
import { chatApi, type ChatThread, type ChatMessage } from "../lib/chatApi";
|
import { chatApi, type ChatThread, type ChatMessage } from "../lib/chatApi";
|
||||||
import { Bot } from "../components/icons";
|
import { Bot } from "../components/icons";
|
||||||
@@ -14,6 +14,26 @@ function personaLabel(email: string): string {
|
|||||||
return PERSONA_DIRECTORY.find((p) => p.email === email)?.label || email;
|
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() {
|
export default function Chat() {
|
||||||
const userEmail = useApp((s) => s.userEmail);
|
const userEmail = useApp((s) => s.userEmail);
|
||||||
const pushToast = useApp((s) => s.pushToast);
|
const pushToast = useApp((s) => s.pushToast);
|
||||||
@@ -22,6 +42,8 @@ export default function Chat() {
|
|||||||
const [threads, setThreads] = useState<ChatThread[]>([]);
|
const [threads, setThreads] = useState<ChatThread[]>([]);
|
||||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
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 [draft, setDraft] = useState("");
|
||||||
const [loadingThreads, setLoadingThreads] = useState(false);
|
const [loadingThreads, setLoadingThreads] = useState(false);
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
@@ -56,6 +78,14 @@ export default function Chat() {
|
|||||||
.then((rows) => {
|
.then((rows) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMessages(rows);
|
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);
|
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }), 50);
|
||||||
})
|
})
|
||||||
.catch((err) => pushToast("err", `Messages failed: ${err.message}`))
|
.catch((err) => pushToast("err", `Messages failed: ${err.message}`))
|
||||||
@@ -65,6 +95,39 @@ export default function Chat() {
|
|||||||
};
|
};
|
||||||
}, [activeKey]);
|
}, [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 () => {
|
const handleNewThread = async () => {
|
||||||
if (!recipient || recipient === me) {
|
if (!recipient || recipient === me) {
|
||||||
pushToast("err", "Pick a recipient that's not yourself");
|
pushToast("err", "Pick a recipient that's not yourself");
|
||||||
@@ -130,18 +193,32 @@ export default function Chat() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-thread-list">
|
<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 && <div className="chat-empty">Loading threads…</div>}
|
||||||
{!loadingThreads && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
|
{!loadingThreads && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
|
||||||
{threads.map((t) => (
|
{threads.map((t) => {
|
||||||
<button
|
const preview = previews[t._key];
|
||||||
key={t._key}
|
const lastFromOther = preview && preview.author_email !== me;
|
||||||
className={`chat-thread-row ${t._key === activeKey ? "active" : ""}`}
|
const readAt = readMap[t._key];
|
||||||
onClick={() => setActiveKey(t._key)}
|
const unread = lastFromOther && (!readAt || preview!.created_at > readAt);
|
||||||
>
|
const previewBody = preview ? `${preview.author_email === me ? "You: " : ""}${preview.body}` : t.description;
|
||||||
<div className="chat-thread-name">{t.display_name}</div>
|
return (
|
||||||
<div className="chat-thread-meta">{t.description}</div>
|
<button
|
||||||
</button>
|
key={t._key}
|
||||||
))}
|
className={`chat-thread-row ${t._key === activeKey ? "active" : ""} ${unread ? "unread" : ""}`}
|
||||||
|
onClick={() => setActiveKey(t._key)}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user