diff --git a/app/(app)/chats/[id]/page.tsx b/app/(app)/chats/[id]/page.tsx index 6d489b29..363085d2 100644 --- a/app/(app)/chats/[id]/page.tsx +++ b/app/(app)/chats/[id]/page.tsx @@ -34,12 +34,14 @@ import { toast } from "sonner"; import { channelAuthorizer } from "@/lib/realtime"; import { cn } from "@/lib/utils"; import { useVisualViewport } from "@/lib/use-visual-viewport"; +import { useNotifications } from "@/components/notifications/notifications-provider"; import { chat, conversationTitle, groupAvatarUrl, isMuted, senderName, + senderPeer, type ChatMessage, type ConversationDetail, type Receipts, @@ -69,6 +71,7 @@ export default function ChatThreadPage() { // instead of dvh — see useVisualViewport for why dvh can't do this on iOS. // Declared with the other hooks: the early return below means anything // placed after it would run conditionally. + const { refreshChatUnread } = useNotifications(); const viewport = useVisualViewport(); const keyboardOpen = viewport?.keyboardOpen ?? false; // Guards against two loads racing (scroll handler + the short-page effect). @@ -322,9 +325,15 @@ export default function ChatThreadPage() { const markRead = useCallback( (seq: number) => { if (!seq) return; - chat.markRead(id, seq).catch(() => {}); + // Refresh the nav badge afterwards: without this it stays lit until the + // next incoming message, since nothing else tells it this thread was + // read. + chat + .markRead(id, seq) + .then(refreshChatUnread) + .catch(() => {}); }, - [id], + [id, refreshChatUnread], ); useEffect(() => { @@ -416,6 +425,10 @@ export default function ChatThreadPage() { const nameFor = (senderId: string | null | undefined) => senderName(senderId, meta?.participants ?? [], user.id); + // Groups need the actual sender, not the conversation's `peer` — `peer` is + // only set on DMs, so a group has no single counterpart to read from. + const senderFor = (senderId: string | null | undefined) => + senderPeer(senderId, meta?.participants ?? []); const title = meta ? conversationTitle(meta) : "Chat"; const muted = meta ? isMuted(meta) : false; @@ -524,7 +537,7 @@ export default function ChatThreadPage() { {!mine && showAvatar && ( )}
+ {/* Name the sender once per run of messages. An avatar alone is thin + identification in a group — two people with similar photos, or + none at all, are indistinguishable at 28px. */} + {!mine && showAvatar && ( + + {sender?.name ?? "Unknown"} + + )} + {messages.map((m, i) => ( // A deleted message has nothing to quote, so it isn't swipeable.
- {m.reply_to && !m.deleted_at && ( - - )} - {m.deleted_at ? ( - This message was deleted - ) : ( - <> - {m.body && } - - {/* An optimistic bubble for a captionless attachment has +
+ {m.reply_to && !m.deleted_at && ( + + )} + {m.deleted_at ? ( + This message was deleted + ) : ( + <> + {m.body && ( + + )} + + {/* An optimistic bubble for a captionless attachment has nothing to draw until the server answers — say so rather than render an empty box. */} - {m.pending && !m.body && m.attachments.length === 0 && ( - Sending attachment… - )} - {starredIds.has(m.id) && ( - - )} - - )} + {m.pending && !m.body && m.attachments.length === 0 && ( + + Sending attachment… + + )} + {starredIds.has(m.id) && ( + + )} + + )} - {/* Desktop affordance. Swiping is the mobile gesture, but a + {/* Desktop affordance. Swiping is the mobile gesture, but a drag is not discoverable with a mouse — and a real button is also reachable by keyboard, which a gesture never is. */} - {!m.deleted_at && !m.pending && ( - - )} + {!m.deleted_at && !m.pending && ( + + )} +
))} @@ -223,8 +247,10 @@ export function MessageGroup({ Not sent — retry )} - {mine && !last.deleted_at && !last.failed && ( - last.pending ? ( + {mine && + !last.deleted_at && + !last.failed && + (last.pending ? ( ) : ( - ) - )} + ))}
diff --git a/components/chat/message-list.tsx b/components/chat/message-list.tsx index 4925bb26..550ca31a 100644 --- a/components/chat/message-list.tsx +++ b/components/chat/message-list.tsx @@ -44,7 +44,7 @@ function dayLabel(iso: string): string { export function MessageList({ messages, selfId, - peer, + senderFor, receipts, isGroup, hasMore, @@ -59,7 +59,8 @@ export function MessageList({ /** Ascending by seq — oldest first. */ messages: ChatMessage[]; selfId: string; - peer?: ChatPeer | null; + /** Resolves a message's sender for their avatar and name. */ + senderFor: (senderId: string | null | undefined) => ChatPeer | undefined; receipts: Receipts | null; isGroup: boolean; hasMore: boolean; @@ -133,7 +134,10 @@ export function MessageList({
{loadingMore && (

@@ -160,7 +164,7 @@ export function MessageList({ key={row.key} messages={row.messages} mine={row.senderId === selfId} - peer={peer} + sender={senderFor(row.senderId)} receipts={receipts} showAvatar={isGroup} selfId={selfId} diff --git a/components/layout/nav-unread-badge.tsx b/components/layout/nav-unread-badge.tsx new file mode 100644 index 00000000..9da2e2c8 --- /dev/null +++ b/components/layout/nav-unread-badge.tsx @@ -0,0 +1,38 @@ +"use client"; + +/** + * The unread badge on a nav entry. + * + * Counts threads waiting on you, not messages: one busy group shouldn't read + * as twenty things needing attention. Muted threads are excluded server-side, + * so muting a noisy group clears the badge while its unread count stays + * visible in the inbox. + * + * Wraps its icon rather than being positioned by each caller, so the sidebar + * and bottom bar can't drift apart. + */ + +import { useNotifications } from "@/components/notifications/notifications-provider"; + +export function NavUnreadBadge({ children }: { children: React.ReactNode }) { + const { chatUnread } = useNotifications(); + if (chatUnread <= 0) return <>{children}; + + return ( + + {children} + + {chatUnread > 9 ? "9+" : chatUnread} + + + ); +} + +/** The count, for callers that need it in an accessible name. */ +export function useChatUnread(): number { + return useNotifications().chatUnread; +} diff --git a/components/layout/site-bottom-nav.tsx b/components/layout/site-bottom-nav.tsx index c791d2e4..ec346170 100644 --- a/components/layout/site-bottom-nav.tsx +++ b/components/layout/site-bottom-nav.tsx @@ -14,6 +14,10 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Home, MessageCircle, Search, User as UserIcon } from "lucide-react"; +import { + NavUnreadBadge, + useChatUnread, +} from "@/components/layout/nav-unread-badge"; import { useAuth } from "@/components/auth/auth-provider"; import { cn } from "@/lib/utils"; @@ -28,6 +32,9 @@ type Item = { export function SiteBottomNav() { const pathname = usePathname(); const { user } = useAuth(); + // Read here rather than inside the badge so the count can also reach the + // link's accessible name — the badge itself is decorative. + const chatUnread = useChatUnread(); const items: Item[] = [ { href: "/", label: "Home", icon: }, @@ -113,7 +120,11 @@ export function SiteBottomNav() { > 0 + ? `${item.label} (${chatUnread} unread)` + : item.label + } aria-current={active ? "page" : undefined} className={cn( "w-12 h-12 rounded-md flex flex-col items-center justify-center", @@ -122,7 +133,11 @@ export function SiteBottomNav() { : "text-muted-foreground hover:text-foreground", )} > - {item.icon} + {item.href === "/chats" ? ( + {item.icon} + ) : ( + item.icon + )} ); diff --git a/components/layout/site-sidebar.tsx b/components/layout/site-sidebar.tsx index 35e7f07e..d69c5f54 100644 --- a/components/layout/site-sidebar.tsx +++ b/components/layout/site-sidebar.tsx @@ -40,6 +40,10 @@ import { Settings2, User as UserIcon, } from "lucide-react"; +import { + NavUnreadBadge, + useChatUnread, +} from "@/components/layout/nav-unread-badge"; import { useAuth } from "@/components/auth/auth-provider"; import { ReadingSettings } from "@/components/reading-settings"; import { KautilyaIcon } from "@/components/selection-toolbar/shared"; @@ -177,10 +181,15 @@ function SidebarLink({ * settings popover is up) and the mouse isn't actually hovering. */ expandedOverride?: boolean; }) { + const chatUnread = useChatUnread(); return ( 0 + ? `${item.label} (${chatUnread} unread)` + : item.label + } title={item.label} className={cn( "mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium", @@ -190,7 +199,13 @@ function SidebarLink({ : "text-muted-foreground hover:text-foreground hover:bg-muted", )} > - {item.icon} + + {item.href === "/chats" ? ( + {item.icon} + ) : ( + item.icon + )} + void; }; /** Payload of the BFF's `chat_activity` event. */ @@ -108,6 +114,7 @@ export function NotificationsProvider({ }) { const { user } = useAuth(); const [unread, setUnread] = useState(0); + const [chatUnread, setChatUnread] = useState(0); const [lastEventAt, setLastEventAt] = useState(0); const [lastChatActivity, setLastChatActivity] = useState( null, @@ -124,12 +131,22 @@ export function NotificationsProvider({ const clearUnread = useCallback(() => setUnread(0), []); + const refreshChatUnread = useCallback(() => { + chat + .unreadCount() + .then(setChatUnread) + .catch(() => { + /* badge is non-critical */ + }); + }, []); + // Seed the count whenever the signed-in user changes (async — no // synchronous setState in the effect body). useEffect(() => { if (!user?.id) return; refreshUnread(); - }, [user?.id, refreshUnread]); + refreshChatUnread(); + }, [user?.id, refreshUnread, refreshChatUnread]); // Report the browser's timezone (once per signed-in session) so sādhana // reminders fire at the user's local clock time. Best-effort. @@ -158,7 +175,7 @@ export function NotificationsProvider({ }; window.addEventListener("pagehide", onLeave); return () => window.removeEventListener("pagehide", onLeave); - }, [userId]); + }, [userId, refreshChatUnread]); // Open the realtime connection while signed in. useEffect(() => { @@ -197,13 +214,24 @@ export function NotificationsProvider({ channel.bind("pusher:subscription_error", (status: unknown) => { console.warn("notifications: realtime subscription failed", status); }); - // Chat activity: no toast, no unread bump, nothing in the tray. The - // inbox subscribes to this to highlight the affected row. + // Chat activity: no toast, nothing in the tray. The inbox subscribes to + // this to highlight the affected row, and the nav badge re-reads its + // count — refetched rather than incremented, because the badge counts + // THREADS: a second message in a thread already counted must not bump + // it, and the sender's own message must not either. channel.bind( "chat_activity", (data: Omit) => { if (!data?.conversation_id) return; setLastChatActivity({ ...data, at: Date.now() }); + refreshChatUnread(); + // Ack delivery here rather than in the thread, so "delivered" means + // "reached their device" rather than "they happened to have the chat + // open". This event only reaches non-senders, so nobody acks their + // own message. + if (data.seq) { + chat.markDelivered(data.conversation_id, data.seq).catch(() => {}); + } }, ); channel.bind("notification", (data: IncomingEvent) => { @@ -232,7 +260,7 @@ export function NotificationsProvider({ cancelled = true; cleanup?.(); }; - }, [userId]); + }, [userId, refreshChatUnread]); return ( {children} diff --git a/lib/chat.ts b/lib/chat.ts index 5905ff67..475e3d57 100644 --- a/lib/chat.ts +++ b/lib/chat.ts @@ -251,6 +251,10 @@ export const chat = { * Opens (or returns) the DM with a profile. Idempotent, and keyed by handle * because that is what a profile page has. */ + /** Threads waiting on the caller. Muted ones are excluded — see the BFF. */ + unreadCount: () => + call<{ count: number }>("/chat/unread").then((r) => r.count), + openDM: (handle: string) => call<{ id: string; kind: ConversationKind }>("/chat/conversations/dm", { method: "POST", @@ -589,6 +593,21 @@ export function followSummary( } /** Resolves a sender id to a display name using a thread's participants. */ +/** + * The participant who sent a message, or undefined for Kautilya, a system + * message, or someone who has since been removed. + * + * Groups need this rather than the conversation's `peer`: `peer` is only set + * on DMs, so a group has no single counterpart to read an avatar from. + */ +export function senderPeer( + senderId: string | null | undefined, + participants: ChatPeer[], +): ChatPeer | undefined { + if (!senderId) return undefined; + return participants.find((p) => p.id === senderId); +} + export function senderName( senderId: string | null | undefined, participants: ChatPeer[], diff --git a/lib/social.ts b/lib/social.ts index 838100c4..70393b0b 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -712,6 +712,9 @@ export const notifications = { // ─── Notification preferences ────────────────────────────────────────── export type PrefEvent = + /** Chat push. Delivered by the chat service directly, not the notification + * tray — a new message highlights its inbox row instead. */ + | "messages" | "follows" | "post_interactions" | "sadhna_reminders"