fix: chat UI

This commit is contained in:
gosangam
2026-08-29 17:30:32 +05:30
parent 3e6a1c6f39
commit 011b5bba91
11 changed files with 250 additions and 76 deletions
+16 -3
View File
@@ -34,12 +34,14 @@ import { toast } from "sonner";
import { channelAuthorizer } from "@/lib/realtime"; import { channelAuthorizer } from "@/lib/realtime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useVisualViewport } from "@/lib/use-visual-viewport"; import { useVisualViewport } from "@/lib/use-visual-viewport";
import { useNotifications } from "@/components/notifications/notifications-provider";
import { import {
chat, chat,
conversationTitle, conversationTitle,
groupAvatarUrl, groupAvatarUrl,
isMuted, isMuted,
senderName, senderName,
senderPeer,
type ChatMessage, type ChatMessage,
type ConversationDetail, type ConversationDetail,
type Receipts, type Receipts,
@@ -69,6 +71,7 @@ export default function ChatThreadPage() {
// instead of dvh — see useVisualViewport for why dvh can't do this on iOS. // 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 // Declared with the other hooks: the early return below means anything
// placed after it would run conditionally. // placed after it would run conditionally.
const { refreshChatUnread } = useNotifications();
const viewport = useVisualViewport(); const viewport = useVisualViewport();
const keyboardOpen = viewport?.keyboardOpen ?? false; const keyboardOpen = viewport?.keyboardOpen ?? false;
// Guards against two loads racing (scroll handler + the short-page effect). // Guards against two loads racing (scroll handler + the short-page effect).
@@ -322,9 +325,15 @@ export default function ChatThreadPage() {
const markRead = useCallback( const markRead = useCallback(
(seq: number) => { (seq: number) => {
if (!seq) return; 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(() => { useEffect(() => {
@@ -416,6 +425,10 @@ export default function ChatThreadPage() {
const nameFor = (senderId: string | null | undefined) => const nameFor = (senderId: string | null | undefined) =>
senderName(senderId, meta?.participants ?? [], user.id); 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 title = meta ? conversationTitle(meta) : "Chat";
const muted = meta ? isMuted(meta) : false; const muted = meta ? isMuted(meta) : false;
@@ -524,7 +537,7 @@ export default function ChatThreadPage() {
<MessageList <MessageList
messages={messages} messages={messages}
selfId={user.id} selfId={user.id}
peer={meta?.peer} senderFor={senderFor}
receipts={receipts} receipts={receipts}
isGroup={meta?.kind === "group"} isGroup={meta?.kind === "group"}
hasMore={hasMore} hasMore={hasMore}
+4 -1
View File
@@ -135,7 +135,10 @@ export function MessageGestures({
return ( return (
<div <div
className="relative" // Full width so the whole row is draggable — WhatsApp lets you start the
// gesture anywhere on the line, including the empty space opposite the
// bubble. The bubble caps its own width inside this.
className="relative w-full"
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
+38 -13
View File
@@ -81,7 +81,7 @@ function QuotedReply({
export function MessageGroup({ export function MessageGroup({
messages, messages,
mine, mine,
peer, sender,
receipts, receipts,
showAvatar, showAvatar,
selfId, selfId,
@@ -93,7 +93,9 @@ export function MessageGroup({
}: { }: {
messages: ChatMessage[]; messages: ChatMessage[];
mine: boolean; mine: boolean;
peer?: ChatPeer | null; /** Who sent this group of messages. Undefined for Kautilya, system
* messages, or a member who has since left. */
sender?: ChatPeer;
receipts: Receipts | null; receipts: Receipts | null;
/** Groups show an avatar; a DM's own side never does. */ /** Groups show an avatar; a DM's own side never does. */
showAvatar: boolean; showAvatar: boolean;
@@ -119,20 +121,31 @@ export function MessageGroup({
> >
{!mine && showAvatar && ( {!mine && showAvatar && (
<UserAvatar <UserAvatar
src={peer?.avatar_url} src={sender?.avatar_url}
name={peer?.name ?? "?"} name={sender?.name ?? "?"}
className="size-7 shrink-0" className="size-7 shrink-0"
textClassName="text-[10px]" textClassName="text-[10px]"
/> />
)} )}
<div <div
// flex-1 rather than max-w: the column spans the row so a swipe can
// start anywhere on it. min-w-0 lets long words wrap instead of
// widening the row.
className={cn( className={cn(
"flex max-w-[78%] flex-col gap-0.5", "flex min-w-0 flex-1 flex-col gap-0.5",
mine ? "items-end" : "items-start", mine ? "items-end" : "items-start",
!mine && !showAvatar && "ml-9",
)} )}
> >
{/* 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 && (
<span className="px-1 text-[11px] font-medium text-muted-foreground">
{sender?.name ?? "Unknown"}
</span>
)}
{messages.map((m, i) => ( {messages.map((m, i) => (
// A deleted message has nothing to quote, so it isn't swipeable. // A deleted message has nothing to quote, so it isn't swipeable.
<MessageGestures <MessageGestures
@@ -143,7 +156,13 @@ export function MessageGroup({
> >
<div <div
className={cn( className={cn(
"group/bubble relative px-3 py-2 text-sm", "flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"group/bubble relative max-w-[78%] px-3 py-2 text-sm",
bubbleCorners(mine, i, messages.length), bubbleCorners(mine, i, messages.length),
m.deleted_at m.deleted_at
? "bg-muted italic text-muted-foreground" ? "bg-muted italic text-muted-foreground"
@@ -164,13 +183,17 @@ export function MessageGroup({
<span>This message was deleted</span> <span>This message was deleted</span>
) : ( ) : (
<> <>
{m.body && <MessageText body={m.body} entities={m.entities} />} {m.body && (
<MessageText body={m.body} entities={m.entities} />
)}
<MessageAttachments attachments={m.attachments} /> <MessageAttachments attachments={m.attachments} />
{/* An optimistic bubble for a captionless attachment has {/* An optimistic bubble for a captionless attachment has
nothing to draw until the server answers — say so rather nothing to draw until the server answers — say so rather
than render an empty box. */} than render an empty box. */}
{m.pending && !m.body && m.attachments.length === 0 && ( {m.pending && !m.body && m.attachments.length === 0 && (
<span className="italic opacity-80">Sending attachment…</span> <span className="italic opacity-80">
Sending attachment…
</span>
)} )}
{starredIds.has(m.id) && ( {starredIds.has(m.id) && (
<Star <Star
@@ -200,6 +223,7 @@ export function MessageGroup({
</button> </button>
)} )}
</div> </div>
</div>
</MessageGestures> </MessageGestures>
))} ))}
@@ -223,8 +247,10 @@ export function MessageGroup({
Not sent — retry Not sent — retry
</button> </button>
)} )}
{mine && !last.deleted_at && !last.failed && ( {mine &&
last.pending ? ( !last.deleted_at &&
!last.failed &&
(last.pending ? (
<Clock <Clock
role="img" role="img"
aria-label="Sending" aria-label="Sending"
@@ -232,8 +258,7 @@ export function MessageGroup({
/> />
) : ( ) : (
<MessageTicks state={tickState(last.seq, receipts)} /> <MessageTicks state={tickState(last.seq, receipts)} />
) ))}
)}
</div> </div>
</div> </div>
</div> </div>
+8 -4
View File
@@ -44,7 +44,7 @@ function dayLabel(iso: string): string {
export function MessageList({ export function MessageList({
messages, messages,
selfId, selfId,
peer, senderFor,
receipts, receipts,
isGroup, isGroup,
hasMore, hasMore,
@@ -59,7 +59,8 @@ export function MessageList({
/** Ascending by seq — oldest first. */ /** Ascending by seq — oldest first. */
messages: ChatMessage[]; messages: ChatMessage[];
selfId: string; 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; receipts: Receipts | null;
isGroup: boolean; isGroup: boolean;
hasMore: boolean; hasMore: boolean;
@@ -133,7 +134,10 @@ export function MessageList({
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={onScroll} onScroll={onScroll}
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4" // overflow-x-hidden is load-bearing: `overflow-y: auto` computes the
// other axis to `auto` rather than `visible`, so the swipe-to-reply
// transform would otherwise give the thread a horizontal scrollbar.
className="flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-4"
> >
{loadingMore && ( {loadingMore && (
<p className="pb-3 text-center text-xs text-muted-foreground"> <p className="pb-3 text-center text-xs text-muted-foreground">
@@ -160,7 +164,7 @@ export function MessageList({
key={row.key} key={row.key}
messages={row.messages} messages={row.messages}
mine={row.senderId === selfId} mine={row.senderId === selfId}
peer={peer} sender={senderFor(row.senderId)}
receipts={receipts} receipts={receipts}
showAvatar={isGroup} showAvatar={isGroup}
selfId={selfId} selfId={selfId}
+38
View File
@@ -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 (
<span className="relative inline-flex">
{children}
<span
// Announced through the link's aria-label instead; this is decoration.
aria-hidden
className="absolute -right-1.5 -top-1 min-w-4 rounded-full bg-primary px-1 text-center text-[10px] font-semibold leading-4 text-white ring-2 ring-background"
>
{chatUnread > 9 ? "9+" : chatUnread}
</span>
</span>
);
}
/** The count, for callers that need it in an accessible name. */
export function useChatUnread(): number {
return useNotifications().chatUnread;
}
+17 -2
View File
@@ -14,6 +14,10 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Home, MessageCircle, Search, User as UserIcon } from "lucide-react"; 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 { useAuth } from "@/components/auth/auth-provider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -28,6 +32,9 @@ type Item = {
export function SiteBottomNav() { export function SiteBottomNav() {
const pathname = usePathname(); const pathname = usePathname();
const { user } = useAuth(); 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[] = [ const items: Item[] = [
{ href: "/", label: "Home", icon: <Home className="size-5" /> }, { href: "/", label: "Home", icon: <Home className="size-5" /> },
@@ -113,7 +120,11 @@ export function SiteBottomNav() {
> >
<Link <Link
href={item.href} href={item.href}
aria-label={item.label} aria-label={
item.href === "/chats" && chatUnread > 0
? `${item.label} (${chatUnread} unread)`
: item.label
}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={cn( className={cn(
"w-12 h-12 rounded-md flex flex-col items-center justify-center", "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", : "text-muted-foreground hover:text-foreground",
)} )}
> >
{item.icon} {item.href === "/chats" ? (
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
) : (
item.icon
)}
</Link> </Link>
</li> </li>
); );
+17 -2
View File
@@ -40,6 +40,10 @@ import {
Settings2, Settings2,
User as UserIcon, User as UserIcon,
} from "lucide-react"; } from "lucide-react";
import {
NavUnreadBadge,
useChatUnread,
} from "@/components/layout/nav-unread-badge";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { ReadingSettings } from "@/components/reading-settings"; import { ReadingSettings } from "@/components/reading-settings";
import { KautilyaIcon } from "@/components/selection-toolbar/shared"; import { KautilyaIcon } from "@/components/selection-toolbar/shared";
@@ -177,10 +181,15 @@ function SidebarLink({
* settings popover is up) and the mouse isn't actually hovering. */ * settings popover is up) and the mouse isn't actually hovering. */
expandedOverride?: boolean; expandedOverride?: boolean;
}) { }) {
const chatUnread = useChatUnread();
return ( return (
<Link <Link
href={item.href} href={item.href}
aria-label={item.label} aria-label={
item.href === "/chats" && chatUnread > 0
? `${item.label} (${chatUnread} unread)`
: item.label
}
title={item.label} title={item.label}
className={cn( className={cn(
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium", "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", : "text-muted-foreground hover:text-foreground hover:bg-muted",
)} )}
> >
<span className="shrink-0">{item.icon}</span> <span className="shrink-0">
{item.href === "/chats" ? (
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
) : (
item.icon
)}
</span>
<span <span
className={cn( className={cn(
"transition-opacity duration-150", "transition-opacity duration-150",
@@ -50,6 +50,15 @@ const EVENTS: {
label: "Sādhana reminders", label: "Sādhana reminders",
description: "Daily nudges to chant your rounds and share a verse.", description: "Daily nudges to chant your rounds and share a verse.",
}, },
{
key: "messages",
label: "Messages",
description: "New direct messages and group chats.",
// Push only. In-app delivery for chat is the inbox itself — a message
// highlights its row rather than entering the notification tray — and
// there is no such thing as an emailed chat message.
channels: ["push"],
},
{ {
key: "community", key: "community",
label: "Community", label: "Community",
@@ -34,6 +34,7 @@ import { useAuth } from "@/components/auth/auth-provider";
import { channelAuthorizer } from "@/lib/realtime"; import { channelAuthorizer } from "@/lib/realtime";
import { feed, notifications } from "@/lib/social"; import { feed, notifications } from "@/lib/social";
import { sadhna } from "@/lib/sadhna"; import { sadhna } from "@/lib/sadhna";
import { chat } from "@/lib/chat";
type NotificationsContextValue = { type NotificationsContextValue = {
/** Live unread count for the header bell. */ /** Live unread count for the header bell. */
@@ -51,6 +52,11 @@ type NotificationsContextValue = {
* event name and is surfaced here rather than bumping `unread`. * event name and is surfaced here rather than bumping `unread`.
*/ */
lastChatActivity: ChatActivity | null; lastChatActivity: ChatActivity | null;
/** Threads holding unread messages — the Chats nav badge. Muted threads are
* excluded by the BFF, so muting a busy group clears the badge. */
chatUnread: number;
/** Re-read the authoritative chat count (after opening a thread). */
refreshChatUnread: () => void;
}; };
/** Payload of the BFF's `chat_activity` event. */ /** Payload of the BFF's `chat_activity` event. */
@@ -108,6 +114,7 @@ export function NotificationsProvider({
}) { }) {
const { user } = useAuth(); const { user } = useAuth();
const [unread, setUnread] = useState(0); const [unread, setUnread] = useState(0);
const [chatUnread, setChatUnread] = useState(0);
const [lastEventAt, setLastEventAt] = useState(0); const [lastEventAt, setLastEventAt] = useState(0);
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>( const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
null, null,
@@ -124,12 +131,22 @@ export function NotificationsProvider({
const clearUnread = useCallback(() => setUnread(0), []); 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 // Seed the count whenever the signed-in user changes (async — no
// synchronous setState in the effect body). // synchronous setState in the effect body).
useEffect(() => { useEffect(() => {
if (!user?.id) return; if (!user?.id) return;
refreshUnread(); refreshUnread();
}, [user?.id, refreshUnread]); refreshChatUnread();
}, [user?.id, refreshUnread, refreshChatUnread]);
// Report the browser's timezone (once per signed-in session) so sādhana // Report the browser's timezone (once per signed-in session) so sādhana
// reminders fire at the user's local clock time. Best-effort. // reminders fire at the user's local clock time. Best-effort.
@@ -158,7 +175,7 @@ export function NotificationsProvider({
}; };
window.addEventListener("pagehide", onLeave); window.addEventListener("pagehide", onLeave);
return () => window.removeEventListener("pagehide", onLeave); return () => window.removeEventListener("pagehide", onLeave);
}, [userId]); }, [userId, refreshChatUnread]);
// Open the realtime connection while signed in. // Open the realtime connection while signed in.
useEffect(() => { useEffect(() => {
@@ -197,13 +214,24 @@ export function NotificationsProvider({
channel.bind("pusher:subscription_error", (status: unknown) => { channel.bind("pusher:subscription_error", (status: unknown) => {
console.warn("notifications: realtime subscription failed", status); console.warn("notifications: realtime subscription failed", status);
}); });
// Chat activity: no toast, no unread bump, nothing in the tray. The // Chat activity: no toast, nothing in the tray. The inbox subscribes to
// inbox subscribes to this to highlight the affected row. // 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( channel.bind(
"chat_activity", "chat_activity",
(data: Omit<ChatActivity, "at">) => { (data: Omit<ChatActivity, "at">) => {
if (!data?.conversation_id) return; if (!data?.conversation_id) return;
setLastChatActivity({ ...data, at: Date.now() }); 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) => { channel.bind("notification", (data: IncomingEvent) => {
@@ -232,7 +260,7 @@ export function NotificationsProvider({
cancelled = true; cancelled = true;
cleanup?.(); cleanup?.();
}; };
}, [userId]); }, [userId, refreshChatUnread]);
return ( return (
<NotificationsContext.Provider <NotificationsContext.Provider
@@ -242,6 +270,8 @@ export function NotificationsProvider({
refreshUnread, refreshUnread,
clearUnread, clearUnread,
lastChatActivity, lastChatActivity,
chatUnread,
refreshChatUnread,
}} }}
> >
{children} {children}
+19
View File
@@ -251,6 +251,10 @@ export const chat = {
* Opens (or returns) the DM with a profile. Idempotent, and keyed by handle * Opens (or returns) the DM with a profile. Idempotent, and keyed by handle
* because that is what a profile page has. * 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) => openDM: (handle: string) =>
call<{ id: string; kind: ConversationKind }>("/chat/conversations/dm", { call<{ id: string; kind: ConversationKind }>("/chat/conversations/dm", {
method: "POST", method: "POST",
@@ -589,6 +593,21 @@ export function followSummary(
} }
/** Resolves a sender id to a display name using a thread's participants. */ /** 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( export function senderName(
senderId: string | null | undefined, senderId: string | null | undefined,
participants: ChatPeer[], participants: ChatPeer[],
+3
View File
@@ -712,6 +712,9 @@ export const notifications = {
// ─── Notification preferences ────────────────────────────────────────── // ─── Notification preferences ──────────────────────────────────────────
export type PrefEvent = 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" | "follows"
| "post_interactions" | "post_interactions"
| "sadhna_reminders" | "sadhna_reminders"