fix: chat UI
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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[],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user