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 { 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() {
<MessageList
messages={messages}
selfId={user.id}
peer={meta?.peer}
senderFor={senderFor}
receipts={receipts}
isGroup={meta?.kind === "group"}
hasMore={hasMore}
+4 -1
View File
@@ -135,7 +135,10 @@ export function MessageGestures({
return (
<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}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
+84 -59
View File
@@ -81,7 +81,7 @@ function QuotedReply({
export function MessageGroup({
messages,
mine,
peer,
sender,
receipts,
showAvatar,
selfId,
@@ -93,7 +93,9 @@ export function MessageGroup({
}: {
messages: ChatMessage[];
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;
/** Groups show an avatar; a DM's own side never does. */
showAvatar: boolean;
@@ -119,20 +121,31 @@ export function MessageGroup({
>
{!mine && showAvatar && (
<UserAvatar
src={peer?.avatar_url}
name={peer?.name ?? "?"}
src={sender?.avatar_url}
name={sender?.name ?? "?"}
className="size-7 shrink-0"
textClassName="text-[10px]"
/>
)}
<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(
"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 && !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) => (
// A deleted message has nothing to quote, so it isn't swipeable.
<MessageGestures
@@ -143,62 +156,73 @@ export function MessageGroup({
>
<div
className={cn(
"group/bubble relative px-3 py-2 text-sm",
bubbleCorners(mine, i, messages.length),
m.deleted_at
? "bg-muted italic text-muted-foreground"
: mine
? "bg-primary text-primary-foreground dark:text-white"
: "bg-muted text-foreground",
"flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
{m.reply_to && !m.deleted_at && (
<QuotedReply
reply={m.reply_to}
mine={mine}
selfId={selfId}
nameFor={nameFor}
/>
)}
{m.deleted_at ? (
<span>This message was deleted</span>
) : (
<>
{m.body && <MessageText body={m.body} entities={m.entities} />}
<MessageAttachments attachments={m.attachments} />
{/* An optimistic bubble for a captionless attachment has
<div
className={cn(
"group/bubble relative max-w-[78%] px-3 py-2 text-sm",
bubbleCorners(mine, i, messages.length),
m.deleted_at
? "bg-muted italic text-muted-foreground"
: mine
? "bg-primary text-primary-foreground dark:text-white"
: "bg-muted text-foreground",
)}
>
{m.reply_to && !m.deleted_at && (
<QuotedReply
reply={m.reply_to}
mine={mine}
selfId={selfId}
nameFor={nameFor}
/>
)}
{m.deleted_at ? (
<span>This message was deleted</span>
) : (
<>
{m.body && (
<MessageText body={m.body} entities={m.entities} />
)}
<MessageAttachments attachments={m.attachments} />
{/* 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 && (
<span className="italic opacity-80">Sending attachment…</span>
)}
{starredIds.has(m.id) && (
<Star
className="mt-1 size-3 fill-current opacity-70"
aria-label="Starred"
/>
)}
</>
)}
{m.pending && !m.body && m.attachments.length === 0 && (
<span className="italic opacity-80">
Sending attachment…
</span>
)}
{starredIds.has(m.id) && (
<Star
className="mt-1 size-3 fill-current opacity-70"
aria-label="Starred"
/>
)}
</>
)}
{/* 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 && (
<button
type="button"
onClick={() => onReply(m)}
aria-label="Reply to this message"
className={cn(
"absolute top-1/2 hidden -translate-y-1/2 rounded-full border border-border bg-background p-1.5 text-muted-foreground shadow-sm",
"opacity-0 transition-opacity group-hover/bubble:opacity-100 focus-visible:opacity-100",
"hover:text-foreground sm:block",
mine ? "-left-9" : "-right-9",
)}
>
<Reply className="size-3.5" />
</button>
)}
{!m.deleted_at && !m.pending && (
<button
type="button"
onClick={() => onReply(m)}
aria-label="Reply to this message"
className={cn(
"absolute top-1/2 hidden -translate-y-1/2 rounded-full border border-border bg-background p-1.5 text-muted-foreground shadow-sm",
"opacity-0 transition-opacity group-hover/bubble:opacity-100 focus-visible:opacity-100",
"hover:text-foreground sm:block",
mine ? "-left-9" : "-right-9",
)}
>
<Reply className="size-3.5" />
</button>
)}
</div>
</div>
</MessageGestures>
))}
@@ -223,8 +247,10 @@ export function MessageGroup({
Not sent — retry
</button>
)}
{mine && !last.deleted_at && !last.failed && (
last.pending ? (
{mine &&
!last.deleted_at &&
!last.failed &&
(last.pending ? (
<Clock
role="img"
aria-label="Sending"
@@ -232,8 +258,7 @@ export function MessageGroup({
/>
) : (
<MessageTicks state={tickState(last.seq, receipts)} />
)
)}
))}
</div>
</div>
</div>
+8 -4
View File
@@ -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({
<div
ref={scrollRef}
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 && (
<p className="pb-3 text-center text-xs text-muted-foreground">
@@ -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}
+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 { 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: <Home className="size-5" /> },
@@ -113,7 +120,11 @@ export function SiteBottomNav() {
>
<Link
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}
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" ? (
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
) : (
item.icon
)}
</Link>
</li>
);
+17 -2
View File
@@ -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 (
<Link
href={item.href}
aria-label={item.label}
aria-label={
item.href === "/chats" && chatUnread > 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",
)}
>
<span className="shrink-0">{item.icon}</span>
<span className="shrink-0">
{item.href === "/chats" ? (
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
) : (
item.icon
)}
</span>
<span
className={cn(
"transition-opacity duration-150",
@@ -50,6 +50,15 @@ const EVENTS: {
label: "Sādhana reminders",
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",
label: "Community",
@@ -34,6 +34,7 @@ import { useAuth } from "@/components/auth/auth-provider";
import { channelAuthorizer } from "@/lib/realtime";
import { feed, notifications } from "@/lib/social";
import { sadhna } from "@/lib/sadhna";
import { chat } from "@/lib/chat";
type NotificationsContextValue = {
/** Live unread count for the header bell. */
@@ -51,6 +52,11 @@ type NotificationsContextValue = {
* event name and is surfaced here rather than bumping `unread`.
*/
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. */
@@ -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<ChatActivity | null>(
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<ChatActivity, "at">) => {
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 (
<NotificationsContext.Provider
@@ -242,6 +270,8 @@ export function NotificationsProvider({
refreshUnread,
clearUnread,
lastChatActivity,
chatUnread,
refreshChatUnread,
}}
>
{children}
+19
View File
@@ -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[],
+3
View File
@@ -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"