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