9 Commits
Author SHA1 Message Date
gosangam 523b6fa7e8 feat: chat 2026-08-29 03:49:58 +05:30
gosangam 3257efb06a feat: private r2 bucket and secure sockets 2026-08-29 01:21:25 +05:30
gosangam 6083a485de fix: carousel image lightbox 2026-08-09 12:40:50 +05:30
gosangam baf36d3d67 fix: carousel image lightbox 2026-08-09 11:11:35 +05:30
gosangam df49e7afcc fix: new user scroll fix, add image lightbox 2026-08-09 10:58:49 +05:30
gosangam ce373e3e03 fix: profile bio 2026-08-08 00:49:39 +05:30
gosangam 9b7758da99 fix: profile bio 2026-08-08 00:12:34 +05:30
gosangam 6ff29db68a feat: profile bio 2026-08-07 23:47:22 +05:30
gosangam 274c735b4d feat: disciple highlight 2026-08-07 22:47:04 +05:30
54 changed files with 6371 additions and 125 deletions
@@ -5,6 +5,7 @@ import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { UserAvatar } from "@/components/ui/user-avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Search, Search,
@@ -32,6 +33,14 @@ export type DiscipleSummary = {
isVerified: boolean; isVerified: boolean;
}; };
/** A disciple who has a platform account (linked by an admin). */
export type DiscipleLink = {
discipleId: string;
handle: string;
name: string;
avatarURL: string | null;
};
export type LetterCount = { letter: string; count: number }; export type LetterCount = { letter: string; count: number };
export type PlaceGroup = { name: string; count: number }; export type PlaceGroup = { name: string; count: number };
export type YearGroup = { year: number; count: number }; export type YearGroup = { year: number; count: number };
@@ -49,6 +58,9 @@ type Props = {
years: YearGroup[]; years: YearGroup[];
totalCount: number; totalCount: number;
filteredCount: number; filteredCount: number;
/** Disciples who have joined the platform — their cards are highlighted and
* link through to the profile. Small set (dozens at most). */
links: DiscipleLink[];
}; };
type SortMode = "name-asc" | "name-desc" | "date-asc" | "date-desc" | "place"; type SortMode = "name-asc" | "name-desc" | "date-asc" | "date-desc" | "place";
@@ -306,6 +318,97 @@ function Pagination({
); );
} }
// ---------------------------------------------------------------------------
// Disciple card
// ---------------------------------------------------------------------------
/**
* One disciple in the grid.
*
* When `link` is set the disciple has joined the platform, and the card is
* highlighted and becomes a link to their profile. The highlight is drawn
* entirely from theme tokens (`--primary`, `--ring`) rather than fixed colours,
* so it picks up whichever palette the reader has chosen — peacock green under
* Tulasī Garden, deep indigo under Yamunā Mist, and so on.
*/
function DiscipleCard({
disciple,
link,
}: {
disciple: DiscipleSummary;
link?: DiscipleLink;
}) {
const locationYear = formatLocationYear(
disciple.initiationPlace,
disciple.initiationDate,
);
const card = (
<Card
size="sm"
className={cn(
"h-full transition-all",
link
? // On the platform: tinted surface + primary ring so it reads as
// "one of us" at a glance across the grid.
"bg-primary/[0.06] ring-2 ring-primary/40 hover:ring-primary/70 hover:shadow-md"
: "hover:shadow-md hover:ring-foreground/20",
)}
>
<CardContent className="py-3 px-4 space-y-1.5">
<div className="flex items-start justify-between gap-2">
<p
className={cn(
"font-semibold leading-snug truncate",
link ? "text-primary" : "text-foreground",
)}
>
{disciple.initiatedName}
</p>
{disciple.suffix && (
<Badge
variant={link ? "default" : "secondary"}
className="shrink-0 text-[10px]"
>
{disciple.suffix}
</Badge>
)}
</div>
{locationYear && (
<p className="text-xs text-muted-foreground truncate">
{locationYear}
</p>
)}
{link && (
<div className="flex items-center gap-1.5 pt-0.5">
<UserAvatar
src={link.avatarURL}
name={link.name}
className="size-5"
textClassName="text-[9px]"
/>
<span className="truncate text-xs font-medium text-primary">
@{link.handle}
</span>
</div>
)}
</CardContent>
</Card>
);
if (!link) return card;
return (
<Link
href={`/u/${link.handle}`}
aria-label={`${disciple.initiatedName} — view @${link.handle}'s profile`}
className="block rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{card}
</Link>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Component // Component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -318,6 +421,7 @@ export function DisciplesBrowser({
suffixCounts, suffixCounts,
totalCount, totalCount,
filteredCount, filteredCount,
links,
}: Props) { }: Props) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("name-asc"); const [sort, setSort] = useState<SortMode>("name-asc");
@@ -334,6 +438,13 @@ export function DisciplesBrowser({
setPage(1); setPage(1);
}; };
// Disciple id → their platform account, for the highlight + profile link.
const linkByDiscipleId = useMemo(() => {
const map = new Map<string, DiscipleLink>();
for (const l of links) map.set(l.discipleId, l);
return map;
}, [links]);
// Build a lookup map for letter counts // Build a lookup map for letter counts
const countByLetter = useMemo(() => { const countByLetter = useMemo(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
@@ -539,41 +650,13 @@ export function DisciplesBrowser({
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{pageData.map((disciple) => { {pageData.map((disciple) => (
const locationYear = formatLocationYear( <DiscipleCard
disciple.initiationPlace, key={disciple.id}
disciple.initiationDate, disciple={disciple}
); link={linkByDiscipleId.get(disciple.id)}
/>
return ( ))}
<Card
key={disciple.id}
size="sm"
className="transition-all hover:shadow-md hover:ring-foreground/20"
>
<CardContent className="py-3 px-4 space-y-1.5">
<div className="flex items-start justify-between gap-2">
<p className="font-semibold text-foreground leading-snug truncate">
{disciple.initiatedName}
</p>
{disciple.suffix && (
<Badge
variant="secondary"
className="shrink-0 text-[10px]"
>
{disciple.suffix}
</Badge>
)}
</div>
{locationYear && (
<p className="text-xs text-muted-foreground truncate">
{locationYear}
</p>
)}
</CardContent>
</Card>
);
})}
</div> </div>
)} )}
+4
View File
@@ -12,6 +12,7 @@ import {
getDiscipleYears, getDiscipleYears,
getDiscipleSuffixCounts, getDiscipleSuffixCounts,
getFilteredDiscipleCount, getFilteredDiscipleCount,
getDiscipleLinks,
} from "@/lib/disciples"; } from "@/lib/disciples";
import type { SuffixFilter } from "@/lib/disciples"; import type { SuffixFilter } from "@/lib/disciples";
import { DisciplesBrowser } from "./disciples-browser"; import { DisciplesBrowser } from "./disciples-browser";
@@ -62,6 +63,7 @@ export default async function DisciplesPage({
places, places,
years, years,
suffixCounts, suffixCounts,
links,
] = await Promise.all([ ] = await Promise.all([
getTotalDiscipleCount(), getTotalDiscipleCount(),
getFilteredDisciples(letterFilter, activeSuffix), getFilteredDisciples(letterFilter, activeSuffix),
@@ -70,6 +72,7 @@ export default async function DisciplesPage({
getDisciplePlaces(), getDisciplePlaces(),
getDiscipleYears(), getDiscipleYears(),
getDiscipleSuffixCounts(), getDiscipleSuffixCounts(),
getDiscipleLinks(),
]); ]);
// Serialize dates to ISO strings for the client component // Serialize dates to ISO strings for the client component
@@ -105,6 +108,7 @@ export default async function DisciplesPage({
years={years} years={years}
totalCount={totalCount} totalCount={totalCount}
filteredCount={filteredCount} filteredCount={filteredCount}
links={links}
/> />
</main> </main>
</div> </div>
+559
View File
@@ -0,0 +1,559 @@
"use client";
/**
* /chats/[id] — a single thread.
*
* History pages upward from the newest message using `before_seq` (a keyset
* cursor, not an offset), so paging stays stable while new messages keep
* arriving at the top.
*
* Live updates come from the thread's own private channel. Subscribing is
* gated by the BFF: it only signs `private-conversation.{id}` for an actual
* participant, so an unauthorised subscription is refused at the server.
*
* Composer, attachments and the message context menu land in later phases —
* this screen is read + receipts.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, BellOff } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { UserAvatar } from "@/components/ui/user-avatar";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { MessageList } from "@/components/chat/message-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import {
MessageMenu,
type MenuPoint,
} from "@/components/chat/message-menu";
import { ForwardDialog } from "@/components/chat/forward-dialog";
import { toast } from "sonner";
import { channelAuthorizer } from "@/lib/realtime";
import { cn } from "@/lib/utils";
import {
chat,
conversationTitle,
groupAvatarUrl,
isMuted,
senderName,
type ChatMessage,
type ConversationDetail,
type Receipts,
type UploadedAttachment,
} from "@/lib/chat";
const PAGE_SIZE = 30;
export default function ChatThreadPage() {
const { id } = useParams<{ id: string }>();
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [meta, setMeta] = useState<ConversationDetail | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [receipts, setReceipts] = useState<Receipts | null>(null);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
// Guards against two loads racing (scroll handler + the short-page effect).
const loadingRef = useRef(false);
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
/** Newest page first; messages are held ascending for rendering. */
const loadNewest = useCallback(async () => {
try {
const page = await chat.messages(id, 0, PAGE_SIZE);
setMessages([...page].reverse());
setHasMore(page.length === PAGE_SIZE);
setState("ready");
} catch {
setState("error");
}
}, [id]);
const loadOlder = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
const oldest = messages[0]?.seq;
if (!oldest) return;
loadingRef.current = true;
setLoadingMore(true);
try {
const page = await chat.messages(id, oldest, PAGE_SIZE);
if (page.length > 0) {
setMessages((prev) => [...[...page].reverse(), ...prev]);
}
setHasMore(page.length === PAGE_SIZE);
} catch {
// Leave hasMore alone — a transient failure shouldn't permanently end
// history; the next scroll retries.
} finally {
loadingRef.current = false;
setLoadingMore(false);
}
}, [id, hasMore, messages]);
// One request for the thread: title, avatar, mute state, and the
// participant list the reply banner and quoted lines need to name people.
useEffect(() => {
if (!user) return;
let cancelled = false;
chat
.detail(id)
.then((d) => {
if (!cancelled) setMeta(d);
})
.catch(() => {
/* the message list surfaces the failure */
});
return () => {
cancelled = true;
};
}, [id, user]);
useEffect(() => {
if (!user) return;
void loadNewest();
chat.receipts(id).then(setReceipts).catch(() => {});
}, [id, user, loadNewest]);
/**
* Send optimistically: the bubble appears immediately keyed by a
* client-generated id, then the server's version replaces it. That id is
* also what makes a retry idempotent server-side, so a flaky connection
* can't produce two bubbles.
*/
const sendMessage = useCallback(
(
body: string,
replyToId?: string,
previewUrl?: string,
attachments?: UploadedAttachment[],
) => {
const clientMsgId = crypto.randomUUID();
const optimistic: ChatMessage = {
id: clientMsgId,
conversation_id: id,
seq: Number.MAX_SAFE_INTEGER,
sender_id: user?.id ?? null,
kind: "text",
body,
entities: [],
client_msg_id: clientMsgId,
reply_to_id: replyToId ?? null,
attachments: [],
created_at: new Date().toISOString(),
pending: true,
};
setMessages((prev) => [...prev, optimistic]);
chat
.send(id, body, { clientMsgId, replyToId, previewUrl, attachments })
.then((saved) => {
setMessages((prev) => {
// Drop the placeholder and any realtime echo of the same message.
const without = prev.filter(
(m) => m.id !== clientMsgId && m.id !== saved.id,
);
return [...without, saved];
});
})
.catch(() => {
// Keep the bubble and its text — discarding it loses what the user
// wrote. Marking it failed offers a retry instead.
setMessages((prev) =>
prev.map((m) =>
m.id === clientMsgId ? { ...m, pending: false, failed: true } : m,
),
);
});
},
[id, user?.id],
);
/**
* Retry a failed send, REUSING its client_msg_id. If the original actually
* reached the server and only the response was lost, the server returns the
* stored message instead of writing a second one.
*/
const retryMessage = useCallback(
(failed: ChatMessage) => {
const clientMsgId = failed.client_msg_id ?? failed.id;
setMessages((prev) =>
prev.map((m) =>
m.id === failed.id ? { ...m, pending: true, failed: false } : m,
),
);
chat
.send(id, failed.body, {
clientMsgId,
replyToId: failed.reply_to_id ?? undefined,
})
.then((saved) => {
setMessages((prev) => [
...prev.filter((m) => m.id !== failed.id && m.id !== saved.id),
saved,
]);
})
.catch(() => {
setMessages((prev) =>
prev.map((m) =>
m.id === failed.id ? { ...m, pending: false, failed: true } : m,
),
);
});
},
[id],
);
// Which messages the caller has starred, so bubbles can show it.
const loadStars = useCallback(() => {
chat
.starred(id)
.then((rows) => setStarredIds(new Set((rows ?? []).map((m) => m.id))))
.catch(() => {});
}, [id]);
useEffect(() => {
if (!user) return;
loadStars();
}, [user, loadStars]);
const toggleStar = useCallback(
async (m: ChatMessage) => {
const starred = starredIds.has(m.id);
try {
await chat.setStar(id, m.id, !starred);
setStarredIds((prev) => {
const next = new Set(prev);
if (starred) next.delete(m.id);
else next.add(m.id);
return next;
});
} catch {
toast.error("Could not update the star");
}
},
[id, starredIds],
);
const copyMessage = useCallback((m: ChatMessage) => {
// The stored body is clean text — no markers, no markup — so this is
// exactly what the reader sees.
navigator.clipboard
.writeText(m.body)
.then(() => toast.success("Copied"))
.catch(() => toast.error("Could not copy"));
}, []);
const deleteMessage = useCallback(
async (m: ChatMessage, scope: "me" | "everyone") => {
if (
scope === "everyone" &&
!window.confirm("Delete this message for everyone?")
) {
return;
}
try {
await chat.deleteMessage(id, m.id, scope);
if (scope === "me") {
setMessages((prev) => prev.filter((x) => x.id !== m.id));
} else {
// Tombstone in place — the realtime event does the same for others.
setMessages((prev) =>
prev.map((x) =>
x.id === m.id
? { ...x, deleted_at: new Date().toISOString(), body: "", attachments: [] }
: x,
),
);
}
} catch {
toast.error("Could not delete the message");
}
},
[id],
);
const reportMessage = useCallback(
async (m: ChatMessage) => {
const reason = window.prompt(
"Report this message. What's wrong with it?",
"",
);
if (reason === null) return;
try {
await chat.report(id, m.id, "abuse", reason);
toast.success("Reported. Our moderators will review it.");
} catch {
toast.error("Could not send the report");
}
},
[id],
);
/** Mark everything visible as read, so the sender's ticks turn blue. */
const markRead = useCallback(
(seq: number) => {
if (!seq) return;
chat.markRead(id, seq).catch(() => {});
},
[id],
);
useEffect(() => {
// Skip the optimistic sentinel — only a real server seq is worth acking.
const real = messages.filter((m) => !m.pending && !m.failed);
const newest = real[real.length - 1]?.seq;
if (newest) markRead(newest);
}, [messages, markRead]);
// Live thread updates.
useEffect(() => {
if (!user) return;
const key = process.env.NEXT_PUBLIC_SOKETI_KEY;
const host = process.env.NEXT_PUBLIC_SOKETI_HOST;
if (!key || !host) return;
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
const channelName = `private-conversation.${id}`;
let cancelled = false;
let cleanup: (() => void) | undefined;
void import("pusher-js").then(({ default: Pusher }) => {
if (cancelled) return;
const pusher = new Pusher(key, {
wsHost: host,
wsPort: port,
wssPort: port,
forceTLS,
enabledTransports: ["ws", "wss"],
disableStats: true,
cluster: "",
channelAuthorization: { customHandler: channelAuthorizer },
});
const channel = pusher.subscribe(channelName);
channel.bind("message", (m: ChatMessage) => {
setMessages((prev) => {
// The echo of our own send may arrive before or after the HTTP
// response, so match on both ids: the server's and the one we
// generated for the optimistic bubble.
if (
prev.some(
(x) => x.id === m.id || (!!m.client_msg_id && x.id === m.client_msg_id),
)
) {
return prev.map((x) =>
m.client_msg_id && x.id === m.client_msg_id ? m : x,
);
}
return [...prev, m];
});
});
channel.bind("message_deleted", ({ message_id }: { message_id: string }) => {
setMessages((prev) =>
prev.map((x) =>
x.id === message_id
? { ...x, deleted_at: new Date().toISOString(), body: "", attachments: [] }
: x,
),
);
});
channel.bind("receipt", () => {
chat.receipts(id).then(setReceipts).catch(() => {});
});
cleanup = () => {
channel.unbind_all();
pusher.unsubscribe(channelName);
pusher.disconnect();
};
});
return () => {
cancelled = true;
cleanup?.();
};
}, [id, user]);
if (loading || !user) {
return (
<main className="flex-1 px-4 py-6">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
const nameFor = (senderId: string | null | undefined) =>
senderName(senderId, meta?.participants ?? [], user.id);
const title = meta ? conversationTitle(meta) : "Chat";
const muted = meta ? isMuted(meta) : false;
return (
<main
className={cn(
// Match the inbox and profile widths — a thread stretched across a
// desktop monitor gives bubbles an unreadable line length, and the
// eye has to travel between the avatar and the timestamp.
"mx-auto flex w-full max-w-2xl flex-col",
// Side rules so the narrower column reads as a panel rather than
// text floating in space.
"md:border-x md:border-border",
// Fill the viewport MINUS the chrome around it: a sticky h-12/sm:h-14
// header above, and on small screens the h-16 bottom nav the app
// layout reserves with pb-16. Plain 100dvh ignores both and pushes the
// composer below the fold.
// NB: the underscores are load-bearing. Tailwind turns `_` into a
// space, and calc() requires whitespace around a binary `-` — written
// without it the whole declaration is invalid and dropped, which
// collapses this to height:auto.
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
)}
>
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
<Link
href="/chats"
aria-label="Back to chats"
className="-ml-1 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-5" />
</Link>
{meta?.kind === "ai" ? (
<span
aria-hidden
className="inline-flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-4" />
</span>
) : (
<UserAvatar
src={
meta?.kind === "group"
? groupAvatarUrl(id, meta?.avatar_key)
: meta?.peer?.avatar_url
}
name={title}
className="size-9"
/>
)}
<Link
href={`/chats/${id}/profile`}
className="flex min-w-0 flex-1 flex-col text-left hover:opacity-80"
aria-label="Chat details"
>
<div className="flex items-center gap-2">
<h1 className="truncate text-sm font-semibold text-foreground">
{title}
</h1>
{muted && (
<BellOff
className="size-3.5 text-muted-foreground"
aria-label="Muted"
/>
)}
</div>
{meta?.peer?.handle && (
<p className="truncate text-xs text-muted-foreground">
@{meta.peer.handle}
</p>
)}
</Link>
</header>
{state === "error" ? (
<p className="p-4 text-sm text-destructive">
Could not load this conversation.
</p>
) : state === "loading" ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : (
<MessageList
messages={messages}
selfId={user.id}
peer={meta?.peer}
receipts={receipts}
isGroup={meta?.kind === "group"}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadOlder}
onReply={setReplyTo}
onMenu={(message, point) => setMenu({ message, point })}
starredIds={starredIds}
onRetry={retryMessage}
nameFor={nameFor}
/>
)}
{meta?.kind === "ai" ? (
<div className="border-t border-border px-4 py-3 text-center text-xs text-muted-foreground">
Kautilya is coming soon.
</div>
) : (
<ChatComposer
conversationId={id}
replyTo={replyTo}
replyToName={nameFor(replyTo?.sender_id)}
onCancelReply={() => setReplyTo(null)}
onSend={sendMessage}
disabled={state !== "ready"}
/>
)}
{menu && (
<MessageMenu
point={menu.point}
starred={starredIds.has(menu.message.id)}
onClose={() => setMenu(null)}
actions={{
onReply: () => setReplyTo(menu.message),
onCopy: () => copyMessage(menu.message),
onStar: () => void toggleStar(menu.message),
onForward: () => setForwarding(menu.message),
onDeleteForMe: () => void deleteMessage(menu.message, "me"),
// Only the sender may erase a message for everyone.
onDeleteForEveryone:
menu.message.sender_id === user.id
? () => void deleteMessage(menu.message, "everyone")
: undefined,
// Reporting your own message is meaningless.
onReport:
menu.message.sender_id === user.id
? undefined
: () => void reportMessage(menu.message),
}}
/>
)}
{forwarding && (
<ForwardDialog
message={forwarding}
onClose={() => setForwarding(null)}
/>
)}
</main>
);
}
+455
View File
@@ -0,0 +1,455 @@
"use client";
/**
* /chats/[id]/profile — the chat profile.
*
* Shows who you're talking to and how you're connected, then three tabs over
* everything shared in the thread: media, links, and the messages you starred.
*
* The media and links tabs are one index scan each — `conversation_id` is
* denormalized onto attachments precisely so this screen doesn't join back
* through the message table.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
BellOff,
Camera,
Link2,
Settings,
Star,
Images,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { UserAvatar } from "@/components/ui/user-avatar";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { MessageText } from "@/components/chat/message-text";
import { MessageAttachments } from "@/components/chat/message-attachments";
import { MediaLightbox } from "@/components/chat/media-lightbox";
import { ChatProfileHeader } from "@/components/chat/chat-profile-header";
import { GroupMembersTab } from "@/components/chat/group-members-tab";
import { GroupSettingsTab } from "@/components/chat/group-settings-tab";
import {
attachmentUrl,
chat,
conversationTitle,
groupAvatarUrl,
isGroupAdmin,
type Attachment,
type ChatMessage,
type ConversationDetail,
} from "@/lib/chat";
import { cn } from "@/lib/utils";
type Tab = "members" | "media" | "links" | "starred" | "settings";
/** Tabs that load their own data; the rest render from the thread detail. */
type FetchingTab = "media" | "links" | "starred";
const FETCHING_TABS = new Set<Tab>(["media", "links", "starred"]);
/** Type guard, so narrowing survives the Set lookup. */
function isFetchingTab(t: Tab): t is FetchingTab {
return FETCHING_TABS.has(t);
}
export default function ChatProfilePage() {
const { id } = useParams<{ id: string }>();
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [meta, setMeta] = useState<ConversationDetail | null>(null);
// Groups open on Members — who is in the room is the first question there.
// DMs have no member list, so they open on Media.
const [tab, setTab] = useState<Tab>("media");
const [items, setItems] = useState<Attachment[]>([]);
const [starred, setStarred] = useState<ChatMessage[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [lightboxAt, setLightboxAt] = useState<number | null>(null);
// Set once the user picks a tab, so a metadata refresh never overrides them.
const tabChosen = useRef(false);
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
const loadMeta = useCallback(() => {
chat
.detail(id)
.then((d) => {
setMeta(d);
// Only on first load: don't yank the user off a tab they chose.
setTab((current) =>
current === "media" && d.kind === "group" && !tabChosen.current
? "members"
: current,
);
})
.catch(() => setState("error"));
}, [id]);
useEffect(() => {
if (!user) return;
loadMeta();
}, [user, loadMeta]);
// Switching tabs sets "loading" from the click handler rather than from the
// effect, so the effect only ever reports a result. It also means the old
// tab's rows are never briefly shown under the new tab's heading.
const changeTab = (next: Tab) => {
if (next === tab) return;
tabChosen.current = true;
if (FETCHING_TABS.has(next)) setState("loading");
setTab(next);
};
useEffect(() => {
if (!user) return;
// Members and Settings render straight from `meta` and fetch nothing, so
// they never enter the loading state to leave (see changeTab).
if (!isFetchingTab(tab)) return;
let cancelled = false;
const request =
tab === "starred"
? chat.starred(id).then((rows) => {
if (!cancelled) setStarred(rows ?? []);
})
: chat.gallery(id, tab).then((rows) => {
if (!cancelled) setItems(rows ?? []);
});
request
.then(() => !cancelled && setState("ready"))
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, [id, tab, user]);
const unstar = async (messageId: string) => {
try {
await chat.setStar(id, messageId, false);
setStarred((prev) => prev.filter((m) => m.id !== messageId));
} catch {
toast.error("Could not remove the star");
}
};
if (loading || !user) {
return (
<main className="flex-1 px-4 py-6">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
const isGroup = meta?.kind === "group";
const title = meta ? conversationTitle(meta) : "Chat";
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-6 space-y-6">
<div className="flex items-center gap-3">
<Link
href={`/chats/${id}`}
aria-label="Back to conversation"
className="-ml-1 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-sm font-medium text-muted-foreground">
Chat details
</h1>
</div>
<div className="flex flex-col items-center gap-3 text-center">
{meta?.kind === "ai" ? (
<span
aria-hidden
className="inline-flex size-20 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-9" />
</span>
) : (
<div className="relative">
<UserAvatar
src={
isGroup
? groupAvatarUrl(id, meta?.avatar_key)
: meta?.peer?.avatar_url
}
name={title}
className="size-20"
textClassName="text-xl"
/>
{/* Admins get a hint here; the control itself lives in Settings so
there is only one place that changes the photo. */}
{isGroup && isGroupAdmin(meta?.my_role) && (
<button
type="button"
onClick={() => changeTab("settings")}
aria-label="Change group photo"
className="absolute -bottom-1 -right-1 rounded-full border border-border bg-background p-1.5 text-muted-foreground shadow-sm hover:text-foreground"
>
<Camera className="size-3.5" />
</button>
)}
</div>
)}
<div>
<div className="flex items-center justify-center gap-2">
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
{meta?.muted_until && (
<BellOff
className="size-4 text-muted-foreground"
aria-label="Muted"
/>
)}
</div>
{meta?.peer?.handle && (
<Link
href={`/u/${meta.peer.handle}`}
className="text-sm text-muted-foreground hover:underline"
>
@{meta.peer.handle}
</Link>
)}
</div>
</div>
{meta && !isGroup && (
<ChatProfileHeader
conversationId={id}
detail={meta}
onChanged={loadMeta}
/>
)}
{meta && isGroup && (
<p className="text-center text-sm text-muted-foreground">
{meta.participants.length}{" "}
{meta.participants.length === 1 ? "member" : "members"}
{meta.send_policy === "admins" && " · only admins can send"}
</p>
)}
<div className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1">
{isGroup && (
<TabButton
active={tab === "members"}
onClick={() => changeTab("members")}
>
<Users className="size-4" /> Members
</TabButton>
)}
<TabButton active={tab === "media"} onClick={() => changeTab("media")}>
<Images className="size-4" /> Media
</TabButton>
<TabButton active={tab === "links"} onClick={() => changeTab("links")}>
<Link2 className="size-4" /> Links
</TabButton>
<TabButton
active={tab === "starred"}
onClick={() => changeTab("starred")}
>
<Star className="size-4" /> Starred
</TabButton>
{isGroup && (
<TabButton
active={tab === "settings"}
onClick={() => changeTab("settings")}
>
<Settings className="size-4" /> Settings
</TabButton>
)}
</div>
{state === "error" ? (
<p className="text-sm text-destructive">Could not load this tab.</p>
) : tab === "members" && meta ? (
<GroupMembersTab
conversationId={id}
detail={meta}
selfId={user.id}
onChanged={loadMeta}
/>
) : tab === "settings" && meta ? (
<GroupSettingsTab
conversationId={id}
detail={meta}
selfHandle={user.handle}
onChanged={loadMeta}
/>
) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : tab === "starred" ? (
starred.length === 0 ? (
<Empty text="No starred messages yet. Star a message to keep it here." />
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{starred.map((m) => (
<li key={m.id} className="flex items-start gap-3 px-4 py-3">
<div className="min-w-0 flex-1 text-sm">
{m.body && (
<MessageText body={m.body} entities={m.entities} />
)}
<MessageAttachments attachments={m.attachments} />
<p className="mt-1 text-xs text-muted-foreground">
{new Date(m.created_at).toLocaleString()}
</p>
</div>
<button
type="button"
onClick={() => unstar(m.id)}
aria-label="Remove star"
className="shrink-0 rounded-md p-1 text-primary hover:opacity-80"
>
<Star className="size-4 fill-current" />
</button>
</li>
))}
</ul>
)
) : items.length === 0 ? (
<Empty
text={
tab === "media"
? "No photos, videos or files shared yet."
: "No links shared yet."
}
/>
) : tab === "media" ? (
<div className="grid grid-cols-3 gap-1 sm:grid-cols-4">
{items.map((a, i) =>
a.kind === "image" || a.kind === "video" ? (
<button
key={a.id}
type="button"
// Index into the media-only list, so the lightbox opens on the
// tile that was tapped.
onClick={() => setLightboxAt(viewableIndex(items, i))}
className="aspect-square overflow-hidden rounded-md"
>
{a.kind === "video" ? (
<video
src={attachmentUrl(a.id)}
preload="metadata"
muted
playsInline
className="size-full object-cover"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachmentUrl(a.id)}
alt={a.file_name ?? "Shared media"}
loading="lazy"
className="size-full object-cover"
/>
)}
</button>
) : (
<a
key={a.id}
href={attachmentUrl(a.id)}
target="_blank"
rel="noopener noreferrer"
className="flex aspect-square items-center justify-center rounded-md bg-muted p-2 text-center text-[11px] text-muted-foreground hover:opacity-80"
>
<span className="line-clamp-3 break-all">
{a.file_name ?? a.kind}
</span>
</a>
),
)}
</div>
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((a) => (
<li key={a.id}>
<a
href={a.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-3 hover:bg-muted/60"
>
<p className="truncate text-sm font-medium text-foreground">
{a.title || a.url}
</p>
{a.site_name && (
<p className="truncate text-xs text-muted-foreground">
{a.site_name}
</p>
)}
</a>
</li>
))}
</ul>
)}
{lightboxAt !== null && (
<MediaLightbox
items={viewable(items)}
startIndex={lightboxAt}
onClose={() => setLightboxAt(null)}
/>
)}
</main>
);
}
/** The gallery mixes files in with media; only media is viewable. */
function viewable(items: Attachment[]): Attachment[] {
return items.filter((a) => a.kind === "image" || a.kind === "video");
}
/** Maps a position in the full gallery to its position among viewable items. */
function viewableIndex(items: Attachment[], galleryIndex: number): number {
return viewable(items.slice(0, galleryIndex)).length;
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-md text-sm font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}
function Empty({ text }: { text: string }) {
return (
<p className="py-12 text-center text-sm text-muted-foreground">{text}</p>
);
}
+238
View File
@@ -0,0 +1,238 @@
"use client";
/**
* /chats — the chat inbox, split into two tabs:
*
* • "Chats" — DMs and groups.
* • "Kautilya" — threads with the AI study companion. The companion itself is
* still coming soon, so the tab renders its own empty state.
*
* Both tabs read the same endpoint with a different `tab` filter: server-side
* they are one pipeline over `conversations.kind`, not two implementations.
*
* New messages arrive as realtime `chat_activity` and highlight the affected
* row in place. They deliberately never reach the notification tray.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { MessageCircle, Users } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useNotifications } from "@/components/notifications/notifications-provider";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { ChatListItem } from "@/components/chat/chat-list-item";
import { NewGroupDialog } from "@/components/chat/new-group-dialog";
import { chat, type InboxItem, type InboxTab } from "@/lib/chat";
import { cn } from "@/lib/utils";
/** How long a row stays highlighted after a message lands. */
const HIGHLIGHT_MS = 4000;
export default function ChatsPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const { lastChatActivity } = useNotifications();
const router = useRouter();
const [tab, setTab] = useState<InboxTab>("chats");
const [items, setItems] = useState<InboxItem[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [highlighted, setHighlighted] = useState<Set<string>>(new Set());
const [creatingGroup, setCreatingGroup] = useState(false);
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// Auth gate — same pattern as /notifications: offer the modal in place, and
// only leave the page if they dismiss it.
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
const load = useCallback(
(which: InboxTab) => {
if (!user) return;
setState("loading");
chat
.inbox(which)
.then((rows) => {
setItems(rows ?? []);
setState("ready");
})
.catch(() => setState("error"));
},
[user],
);
useEffect(() => {
load(tab);
}, [tab, load]);
// Clear pending highlight timers on unmount so they can't fire into an
// unmounted tree.
useEffect(() => {
const pending = timers.current;
return () => {
pending.forEach(clearTimeout);
pending.clear();
};
}, []);
// A message landed: refresh the list so its preview, order and unread count
// are authoritative, and flash the row it belongs to.
useEffect(() => {
if (!lastChatActivity) return;
const { conversation_id } = lastChatActivity;
load(tab);
setHighlighted((prev) => new Set(prev).add(conversation_id));
const existing = timers.current.get(conversation_id);
if (existing) clearTimeout(existing);
timers.current.set(
conversation_id,
setTimeout(() => {
setHighlighted((prev) => {
const next = new Set(prev);
next.delete(conversation_id);
return next;
});
timers.current.delete(conversation_id);
}, HIGHLIGHT_MS),
);
// `tab`/`load` are intentionally excluded: this should run when activity
// arrives, not when the tab changes (which reloads on its own).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastChatActivity]);
if (loading || !user) {
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
<div className="flex items-center justify-between gap-3">
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
Chats
</h1>
{tab === "chats" && (
<button
type="button"
onClick={() => setCreatingGroup(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted/60"
>
<Users className="size-4" /> New group
</button>
)}
</div>
<div className="flex gap-1 rounded-lg bg-muted p-1">
<TabButton active={tab === "chats"} onClick={() => setTab("chats")}>
Chats
</TabButton>
<TabButton
active={tab === "kautilya"}
onClick={() => setTab("kautilya")}
>
Kautilya
</TabButton>
</div>
{state === "error" ? (
<p className="text-sm text-destructive">
Could not load your chats. Please try again.
</p>
) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : items.length === 0 ? (
tab === "kautilya" ? (
<Empty
icon={<KautilyaIcon className="size-8" />}
title="Kautilya is coming soon"
text="An AI study companion tuned on Śrīla Prabhupāda's books, letters, and lectures."
/>
) : (
<Empty
icon={<MessageCircle className="size-8" />}
title="No chats yet"
text="Open someone's profile to start a conversation."
/>
)
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((item) => (
<ChatListItem
key={item.id}
item={item}
selfId={user.id}
highlighted={highlighted.has(item.id)}
/>
))}
</ul>
)}
{creatingGroup && (
<NewGroupDialog onClose={() => setCreatingGroup(false)} />
)}
</main>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 inline-flex items-center justify-center gap-2 h-9 rounded-md text-sm font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}
function Empty({
icon,
title,
text,
}: {
icon: React.ReactNode;
title: string;
text: string;
}) {
return (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<span
aria-hidden
className="inline-flex size-14 items-center justify-center rounded-full bg-primary/10 text-primary"
>
{icon}
</span>
<p className="text-base font-medium text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{text}</p>
</div>
);
}
+2
View File
@@ -350,6 +350,8 @@ function messageFor(n: AppNotification): string {
return "replied to your comment."; return "replied to your comment.";
case "comment_mention": case "comment_mention":
return "mentioned you in a comment."; return "mentioned you in a comment.";
case "disciple_joined":
return "— a direct disciple of Śrīla Prabhupāda — has joined Prabhupāda Says.";
case "admin_message": case "admin_message":
return n.body ?? "sent you a message."; return n.body ?? "sent you a message.";
default: default:
+240
View File
@@ -0,0 +1,240 @@
"use client";
/**
* /settings/chat — chat preferences.
*
* Each control saves on change rather than behind a Save button: these are
* independent switches, and a half-applied form is worse than an immediate
* write that can be toggled back.
*/
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { SettingsShell, Section } from "@/components/settings/settings-ui";
import { SettingRow, ToggleSwitch } from "@/components/settings/setting-row";
import { chat, type ChatSettings } from "@/lib/chat";
import { cn } from "@/lib/utils";
const AGE_CHOICES = [1, 3, 6, 12] as const;
export default function ChatSettingsPage() {
const [settings, setSettings] = useState<ChatSettings | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
chat
.settings()
.then((s) => {
if (cancelled) return;
setSettings(s);
setState("ready");
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, []);
const save = useCallback(
async (patch: Partial<Omit<ChatSettings, "gender_set">>) => {
setBusy(true);
try {
setSettings(await chat.updateSettings(patch));
} catch (err) {
// The BFF explains *why* — notably "add your gender in profile
// settings" — so surface its message rather than a generic failure.
toast.error(
err instanceof Error && err.message
? err.message
: "Could not save that setting",
);
} finally {
setBusy(false);
}
},
[],
);
return (
<SettingsShell
backHref="/settings"
backLabel="Back to settings"
title="Chat"
>
{state === "error" ? (
<p className="text-sm text-destructive">
Could not load your chat settings.
</p>
) : state === "loading" || !settings ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<div className="space-y-8">
<Section
title="Read receipts"
description="If you turn these off, you won't see when others have read your messages either."
>
<SettingRow
label="Send read receipts"
description="People you chat with can see when you've read their messages. Delivery ticks are unaffected."
control={
<ToggleSwitch
label="Send read receipts"
checked={settings.read_receipts}
disabled={busy}
onToggle={() =>
save({ read_receipts: !settings.read_receipts })
}
/>
}
/>
</Section>
<Section
title="Who can message you"
description="This applies to new conversations. People you're already chatting with can still reach you."
>
<div className="space-y-2">
<Choice
active={settings.who_can_message === "anyone"}
disabled={busy}
onClick={() => save({ who_can_message: "anyone" })}
title="Anyone"
description="No restriction."
/>
<Choice
active={settings.who_can_message === "friends"}
disabled={busy}
onClick={() => save({ who_can_message: "friends" })}
title="Your friends"
description="Only people you follow, or who follow you."
/>
<Choice
active={settings.who_can_message === "account_age"}
disabled={busy}
onClick={() => save({ who_can_message: "account_age" })}
title="Established accounts"
description="Only accounts older than a chosen age."
>
{settings.who_can_message === "account_age" && (
<div className="mt-3 flex flex-wrap gap-2">
{AGE_CHOICES.map((months) => (
<button
key={months}
type="button"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
void save({ min_account_age_months: months });
}}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
settings.min_account_age_months === months
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{months} {months === 1 ? "month" : "months"}
</button>
))}
</div>
)}
</Choice>
<Choice
active={settings.who_can_message === "same_gender"}
disabled={busy || !settings.gender_set}
onClick={() => save({ who_can_message: "same_gender" })}
title="Only accounts of your gender"
description={
settings.gender_set
? "Only accounts whose gender matches yours."
: undefined
}
>
{!settings.gender_set && (
<p className="mt-1 text-xs text-muted-foreground">
Add your gender in{" "}
<Link
href="/settings/profile"
className="underline underline-offset-2 hover:text-foreground"
>
profile settings
</Link>{" "}
to use this option.
</p>
)}
</Choice>
</div>
</Section>
<Section
title="Kautilya"
description="Kautilya is the AI study companion, tuned on Śrīla Prabhupāda's books, letters, and lectures."
>
<SettingRow
label="Use your chats to train Kautilya"
description="Your conversations may help improve the assistant's answers. Turning this off excludes them."
control={
<ToggleSwitch
label="Use your chats to train Kautilya"
checked={settings.train_kautilya}
disabled={busy}
onToggle={() =>
save({ train_kautilya: !settings.train_kautilya })
}
/>
}
/>
</Section>
</div>
)}
</SettingsShell>
);
}
function Choice({
active,
disabled,
onClick,
title,
description,
children,
}: {
active: boolean;
disabled?: boolean;
onClick: () => void;
title: string;
description?: string;
children?: React.ReactNode;
}) {
return (
<div
className={cn(
"rounded-xl border p-3 transition-colors",
active ? "border-primary bg-primary/5" : "border-border",
disabled && !active && "opacity-60",
)}
>
<button
type="button"
role="radio"
aria-checked={active}
disabled={disabled}
onClick={onClick}
className="w-full text-left disabled:cursor-default"
>
<span className="block text-sm font-medium text-foreground">
{title}
</span>
{description && (
<span className="block text-xs text-muted-foreground">
{description}
</span>
)}
</button>
{children}
</div>
);
}
+7
View File
@@ -22,6 +22,7 @@ import {
DownloadCloud, DownloadCloud,
LogOut, LogOut,
MessageSquareText, MessageSquareText,
MessagesSquare,
Scale, Scale,
ShieldCheck, ShieldCheck,
UserRound, UserRound,
@@ -49,6 +50,12 @@ export default function SettingsPage() {
label="Reading preferences" label="Reading preferences"
description="Theme, palette, font, and size — synced across devices." description="Theme, palette, font, and size — synced across devices."
/> />
<NavRow
href="/settings/chat"
icon={<MessagesSquare className="size-5" />}
label="Chat"
description="Read receipts, who can message you, and Kautilya training."
/>
<NavRow <NavRow
href="/settings/notifications" href="/settings/notifications"
icon={<Bell className="size-5" />} icon={<Bell className="size-5" />}
+4
View File
@@ -129,6 +129,8 @@ export default function PublicProfileLayout(props: {
interactiveCounts={false} interactiveCounts={false}
joinedAt={data.joined_at} joinedAt={data.joined_at}
country={data.country} country={data.country}
isDirectDisciple={!!data.is_direct_disciple}
bio={data.bio}
/> />
<div className="py-12 text-center"> <div className="py-12 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -174,6 +176,8 @@ export default function PublicProfileLayout(props: {
enableActions enableActions
joinedAt={data.joined_at} joinedAt={data.joined_at}
country={data.country} country={data.country}
isDirectDisciple={!!data.is_direct_disciple}
bio={data.bio}
/> />
<ProfileTabs tabs={tabs} /> <ProfileTabs tabs={tabs} />
<PublicProfileContext.Provider value={data}> <PublicProfileContext.Provider value={data}>
+2
View File
@@ -140,6 +140,8 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
name={user.name} name={user.name}
handle={user.handle} handle={user.handle}
joinedAt={user.created_at} joinedAt={user.created_at}
isDirectDisciple={!!user.is_direct_disciple}
bio={user.bio}
editHref="/settings/profile" editHref="/settings/profile"
postCount={counts?.posts} postCount={counts?.posts}
followerCount={counts?.followers} followerCount={counts?.followers}
+184
View File
@@ -0,0 +1,184 @@
"use client";
/**
* Search for people and add them to a group.
*
* Search runs through the same-origin /api/search/users proxy the search page
* uses. It returns handles, which is exactly what the member API takes — no id
* plumbing needed.
*/
import { useEffect, useState } from "react";
import { Check, Search, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { chat } from "@/lib/chat";
type Candidate = { handle: string; name: string; avatar_url: string | null };
export function AddMembersDialog({
conversationId,
existing,
onClose,
onAdded,
}: {
conversationId: string;
/** Handles already in the group. Filtered out of results entirely. */
existing: string[];
onClose: () => void;
onAdded: () => void;
}) {
const { user } = useAuth();
const [query, setQuery] = useState("");
// Results are stored with the query they belong to, so what's displayed can
// be derived: a stale page from a previous query simply stops matching, and
// nothing has to be cleared from an effect.
const [results, setResults] = useState<{ q: string; rows: Candidate[] }>({
q: "",
rows: [],
});
const [picked, setPicked] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const q = query.trim();
const searchable = q.length >= 2;
// Current members and the caller are dropped rather than shown greyed out:
// this list exists to answer "who can I add?", and anyone already here is
// not an answer to that.
const alreadyIn = new Set(existing);
const visible =
results.q === q
? results.rows.filter(
(u) => u.handle !== user?.handle && !alreadyIn.has(u.handle),
)
: [];
useEffect(() => {
if (!searchable) return;
const timer = setTimeout(() => {
fetch(`/api/search/users?q=${encodeURIComponent(q)}&limit=15`)
.then((r) => r.json())
.then((data: { results?: Candidate[] }) =>
setResults({ q, rows: data.results ?? [] }),
)
.catch(() => setResults({ q, rows: [] }));
}, 300);
return () => clearTimeout(timer);
}, [q, searchable]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const toggle = (handle: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(handle)) next.delete(handle);
else next.add(handle);
return next;
});
const submit = async () => {
if (picked.size === 0) return;
setBusy(true);
try {
await chat.addMembers(conversationId, [...picked]);
toast.success(
picked.size === 1 ? "Member added" : `${picked.size} members added`,
);
onAdded();
onClose();
} catch {
toast.error("Could not add members");
} finally {
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
<div className="absolute inset-0" onClick={onClose} aria-hidden />
<div className="relative z-10 flex max-h-[75vh] w-full max-w-md flex-col overflow-hidden rounded-t-2xl border border-border bg-background sm:rounded-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">Add members</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="border-b border-border p-3">
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name or handle"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{!searchable ? (
<p className="p-4 text-sm text-muted-foreground">
Type at least two characters to search.
</p>
) : visible.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
) : (
<ul className="divide-y divide-border">
{visible.map((u) => (
<li key={u.handle}>
<button
type="button"
onClick={() => toggle(u.handle)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
>
<UserAvatar
src={u.avatar_url}
name={u.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">
{u.name}
</p>
<p className="truncate text-xs text-muted-foreground">
@{u.handle}
</p>
</div>
{picked.has(u.handle) && (
<Check className="size-4 shrink-0 text-primary" />
)}
</button>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-border p-3">
<Button
className="w-full"
disabled={picked.size === 0 || busy}
onClick={submit}
>
{picked.size === 0
? "Select people to add"
: `Add ${picked.size} ${picked.size === 1 ? "person" : "people"}`}
</Button>
</div>
</div>
</div>
);
}
+294
View File
@@ -0,0 +1,294 @@
"use client";
/**
* The message composer.
*
* Formatting is deliberately NOT parsed here — the user types `*bold*` and the
* BFF turns markers into clean text plus spans. Keeping one parser on the
* server means every client renders identically and no client can dictate how
* its text is decorated in someone else's thread.
*
* Link previews and attachments layer onto this in the next phase; nothing here
* needs to change to accommodate them.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Paperclip, SendHorizonal, X } from "lucide-react";
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
import {
ComposerAttachments,
type StagedAttachment,
} from "@/components/chat/composer-attachments";
import { compressImage } from "@/lib/compress-image";
import { cn } from "@/lib/utils";
import {
ACCEPTED_ATTACHMENTS,
chat,
firstUrl,
uploadAttachment,
type ChatMessage,
type LinkPreview,
type UploadedAttachment,
} from "@/lib/chat";
const MAX_ROWS_PX = 140;
export function ChatComposer({
replyTo,
onCancelReply,
onSend,
conversationId,
disabled = false,
placeholder = "Message…",
replyToName,
}: {
conversationId: string;
replyTo: ChatMessage | null;
onCancelReply: () => void;
onSend: (
body: string,
replyToId?: string,
previewUrl?: string,
attachments?: UploadedAttachment[],
) => void;
disabled?: boolean;
placeholder?: string;
/** Display name of whoever wrote the message being replied to. */
replyToName: string;
}) {
const [value, setValue] = useState("");
const [preview, setPreview] = useState<LinkPreview | null>(null);
// URLs the sender dismissed. Kept so re-typing doesn't resurrect a card
// they've already said no to.
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
const [staged, setStaged] = useState<StagedAttachment[]>([]);
const ref = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const lookupSeq = useRef(0);
// Grow with the content, up to a cap.
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
}, [value]);
// Which link, if any, the draft currently wants a card for.
const activeUrl = firstUrl(value);
const wantsPreview = !!activeUrl && !dismissed.has(activeUrl);
// Whether to SHOW a card is derived during render rather than cleared from
// an effect: a stale card for a link the user has since edited away simply
// stops matching, so there is no state to reset.
const visiblePreview =
wantsPreview && preview?.url === activeUrl ? preview : null;
// The effect only fetches. Requests are sequenced so a slow earlier response
// cannot overwrite a newer one, and debounced so a lookup doesn't fire on
// every keystroke.
useEffect(() => {
if (!wantsPreview || !activeUrl) return;
const seq = ++lookupSeq.current;
const timer = setTimeout(() => {
chat
.linkPreview(activeUrl)
.then((p) => {
if (seq === lookupSeq.current) setPreview(p);
})
// No card is a normal outcome — many pages carry no metadata.
.catch(() => {});
}, 500);
return () => clearTimeout(timer);
}, [activeUrl, wantsPreview]);
const dismissPreview = useCallback(() => {
if (activeUrl) setDismissed((prev) => new Set(prev).add(activeUrl));
setPreview(null);
}, [activeUrl]);
// Starting a reply should put the cursor where the user is about to type.
useEffect(() => {
if (replyTo) ref.current?.focus();
}, [replyTo]);
/**
* Uploads each pick straight to the private bucket, so by the time the user
* hits send the bytes are already there and sending is just metadata.
* Images are compressed first, which also strips EXIF — location data
* shouldn't ride along into someone else's chat.
*/
const addFiles = useCallback(
async (files: FileList) => {
for (const original of Array.from(files)) {
const id = crypto.randomUUID();
const file = original.type.startsWith("image/")
? await compressImage(original).catch(() => original)
: original;
const previewUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: undefined;
setStaged((prev) => [
...prev,
{ id, file, previewUrl, status: "uploading" },
]);
uploadAttachment(conversationId, file)
.then((uploaded) => {
setStaged((prev) =>
prev.map((s) =>
s.id === id ? { ...s, status: "ready", uploaded } : s,
),
);
})
.catch(() => {
// One failure shouldn't discard the others — mark it and let the
// user remove it.
setStaged((prev) =>
prev.map((s) => (s.id === id ? { ...s, status: "failed" } : s)),
);
});
}
},
[conversationId],
);
const removeStaged = useCallback((id: string) => {
setStaged((prev) => {
const target = prev.find((s) => s.id === id);
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
return prev.filter((s) => s.id !== id);
});
}, []);
// Object URLs are a leak if the composer unmounts mid-compose.
useEffect(() => {
return () => {
staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl));
};
// Intentionally on unmount only; removeStaged handles the per-chip case.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const uploading = staged.some((s) => s.status === "uploading");
const ready = staged.filter((s) => s.status === "ready" && s.uploaded);
const canSend =
!disabled && !uploading && (value.trim() !== "" || ready.length > 0);
const submit = () => {
if (!canSend) return;
const body = value.trim();
onSend(
body,
replyTo?.id,
visiblePreview?.url,
ready.map((s) => s.uploaded as UploadedAttachment),
);
staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl));
setStaged([]);
setValue("");
setPreview(null);
setDismissed(new Set());
onCancelReply();
};
return (
<div className="border-t border-border bg-background">
{replyTo && (
<div className="flex items-start gap-2 border-b border-border px-4 py-2">
<div className="min-w-0 flex-1 border-l-2 border-primary/50 pl-2">
<p className="text-xs font-medium text-foreground">
Replying to {replyToName}
</p>
<p className="line-clamp-1 break-words text-xs text-muted-foreground">
{replyTo.body}
</p>
</div>
<button
type="button"
onClick={onCancelReply}
aria-label="Cancel reply"
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
)}
{visiblePreview && (
<div className="px-4 pt-2">
<LinkPreviewCard
preview={visiblePreview}
onDismiss={dismissPreview}
compact
/>
</div>
)}
<ComposerAttachments items={staged} onRemove={removeStaged} />
<div className="flex items-end gap-2 px-4 py-3">
<input
ref={fileRef}
type="file"
multiple
accept={ACCEPTED_ATTACHMENTS}
className="hidden"
onChange={(e) => {
if (e.target.files?.length) void addFiles(e.target.files);
// Reset so picking the same file twice still fires a change.
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={disabled}
aria-label="Attach a file"
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Paperclip className="size-4" />
</button>
<textarea
ref={ref}
rows={1}
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
// Enter sends on a physical keyboard; Shift+Enter makes a newline.
// On touch keyboards Enter must insert a newline instead — there is
// no Shift to reach for — so the send button is the only way there.
if (e.key !== "Enter" || e.shiftKey) return;
const touch =
typeof window !== "undefined" &&
window.matchMedia("(pointer: coarse)").matches;
if (touch) return;
e.preventDefault();
submit();
}}
className={cn(
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2 text-sm",
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40",
"disabled:opacity-60",
)}
/>
<button
type="button"
onClick={submit}
disabled={!canSend}
aria-label="Send message"
className={cn(
"inline-flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
"bg-primary text-primary-foreground disabled:opacity-40 dark:text-white",
)}
>
<SendHorizonal className="size-4" />
</button>
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
/**
* One row of the chat inbox: avatar, title, the last message (truncated, with
* its sender), the time, and an unread badge.
*
* `highlighted` is driven by realtime `chat_activity` — a new message flashes
* its row here rather than raising a notification, which is what keeps chat out
* of the notification tray.
*/
import Link from "next/link";
import { BellOff } from "lucide-react";
import { UserAvatar } from "@/components/ui/user-avatar";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { timeAgo } from "@/lib/time";
import { cn } from "@/lib/utils";
import {
conversationTitle,
groupAvatarUrl,
isMuted,
type InboxItem,
} from "@/lib/chat";
/** "You: ..." for your own messages, "Name: ..." in groups, bare in a DM. */
function previewText(item: InboxItem, selfId: string): string {
const m = item.last_message;
if (!m) return "No messages yet";
if (m.deleted_at) return "This message was deleted";
const body = m.body || "Attachment";
if (m.sender_id === selfId) return `You: ${body}`;
// A DM's peer is already named by the row title, so repeating it wastes the
// line. Groups need the name to tell speakers apart.
if (item.kind === "group" && m.sender_name) return `${m.sender_name}: ${body}`;
return body;
}
export function ChatListItem({
item,
selfId,
highlighted = false,
}: {
item: InboxItem;
selfId: string;
highlighted?: boolean;
}) {
const muted = isMuted(item);
const unread = item.unread > 0;
return (
<li>
<Link
href={`/chats/${item.id}`}
className={cn(
"flex items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/60",
highlighted && "bg-primary/5",
)}
>
{item.kind === "ai" ? (
<span
aria-hidden
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-5" />
</span>
) : (
<UserAvatar
src={
item.kind === "group"
? groupAvatarUrl(item.id, item.avatar_key)
: item.peer?.avatar_url
}
name={conversationTitle(item)}
className="size-10 shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn(
"truncate text-sm text-foreground",
unread ? "font-semibold" : "font-medium",
)}
>
{conversationTitle(item)}
</span>
{muted && (
<BellOff
className="size-3.5 shrink-0 text-muted-foreground"
aria-label="Muted"
/>
)}
</div>
<p
className={cn(
"truncate text-sm",
unread ? "text-foreground" : "text-muted-foreground",
)}
>
{previewText(item, selfId)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{item.last_message_at && (
<span className="text-xs text-muted-foreground">
{timeAgo(item.last_message_at)}
</span>
)}
{unread && (
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold",
// A muted thread still counts, but shouldn't shout.
muted
? "bg-muted text-muted-foreground"
: "bg-primary text-primary-foreground dark:text-white",
)}
>
{item.unread > 99 ? "99+" : item.unread}
</span>
)}
</div>
</Link>
</li>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
/**
* The relationship block on a chat profile: how long you've been connected,
* which way the follow runs, mute, and blocking.
*
* One-way follows are stated plainly rather than collapsed into "friends" —
* "they follow you" and "you follow each other" are different facts, and the
* profile is where someone goes to check exactly that.
*
* Centred, to sit under the avatar and name above it — which are centred too.
*/
import { useState } from "react";
import { BellOff, Bell, ShieldBan, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { relationships } from "@/lib/social";
import {
chat,
followSummary,
isMuted,
type ConversationDetail,
type MuteDuration,
} from "@/lib/chat";
function friendsSinceLabel(iso?: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return `Connected since ${d.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
})}`;
}
export function ChatProfileHeader({
conversationId,
detail,
onChanged,
}: {
conversationId: string;
detail: ConversationDetail;
onChanged: () => void;
}) {
const [busy, setBusy] = useState(false);
const rel = detail.relationship;
const peer = detail.peer;
const muted = isMuted(detail);
const setMute = async (duration: MuteDuration) => {
setBusy(true);
try {
await chat.setMute(conversationId, duration);
toast.success(duration === "off" ? "Chat unmuted" : "Chat muted");
onChanged();
} catch {
toast.error("Could not change mute settings");
} finally {
setBusy(false);
}
};
const toggleBlock = async () => {
if (!peer) return;
const blocking = !rel?.blocked;
if (blocking && !window.confirm(`Block ${peer.name}? They won't be able to message you.`)) {
return;
}
setBusy(true);
try {
if (blocking) await relationships.block(peer.handle);
else await relationships.unblock(peer.handle);
toast.success(blocking ? `Blocked ${peer.name}` : `Unblocked ${peer.name}`);
onChanged();
} catch {
toast.error("Could not update block settings");
} finally {
setBusy(false);
}
};
return (
<div className="space-y-4 p-4">
{peer && rel && (
<div className="space-y-1 text-center text-sm">
<p className="text-foreground">{followSummary(rel, peer.name)}</p>
{friendsSinceLabel(rel.friends_since) && (
<p className="text-muted-foreground">
{friendsSinceLabel(rel.friends_since)}
</p>
)}
{rel.blocked_by && (
<p className="text-muted-foreground">
{peer.name} has blocked you.
</p>
)}
</div>
)}
<div className="flex flex-wrap justify-center gap-2">
{muted ? (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => setMute("off")}
>
<Bell className="size-4" /> Unmute
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => setMute("8h")}
>
<BellOff className="size-4" /> Mute 8h
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => setMute("always")}
>
Mute always
</Button>
</>
)}
{peer && (
<Button
variant={rel?.blocked ? "outline" : "destructive"}
size="sm"
disabled={busy}
onClick={toggleBlock}
>
{rel?.blocked ? (
<>
<ShieldCheck className="size-4" /> Unblock
</>
) : (
<>
<ShieldBan className="size-4" /> Block
</>
)}
</Button>
)}
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
/**
* The row of files staged in the composer before a message is sent.
*
* Each chip shows its own upload state, because one failure among several
* shouldn't discard the rest — the user removes the failed one and sends.
*/
import { AlertCircle, FileText, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { UploadedAttachment } from "@/lib/chat";
export type StagedAttachment = {
/** Client-side id; the upload has no server identity until it is sent. */
id: string;
file: File;
/** Object URL for an image thumbnail, revoked when the chip is removed. */
previewUrl?: string;
status: "uploading" | "ready" | "failed";
uploaded?: UploadedAttachment;
};
export function ComposerAttachments({
items,
onRemove,
}: {
items: StagedAttachment[];
onRemove: (id: string) => void;
}) {
if (items.length === 0) return null;
return (
<div className="flex flex-wrap gap-2 px-4 pt-2">
{items.map((item) => (
<div
key={item.id}
className={cn(
"relative flex items-center gap-2 rounded-lg border border-border bg-muted/40 p-1.5",
item.status === "failed" && "border-destructive/60",
)}
>
{item.previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.previewUrl}
alt=""
className="size-10 rounded object-cover"
/>
) : (
<span className="inline-flex size-10 items-center justify-center rounded bg-background text-muted-foreground">
<FileText className="size-4" />
</span>
)}
<span className="max-w-32 truncate text-xs text-foreground">
{item.file.name}
</span>
{item.status === "uploading" && (
<Loader2
className="size-3.5 shrink-0 animate-spin text-muted-foreground"
aria-label="Uploading"
/>
)}
{item.status === "failed" && (
<AlertCircle
className="size-3.5 shrink-0 text-destructive"
aria-label="Upload failed"
/>
)}
<button
type="button"
onClick={() => onRemove(item.id)}
aria-label={`Remove ${item.file.name}`}
className="ml-1 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
/**
* Pick a thread to forward a message into.
*
* The destination list is the caller's own inbox, so there is no way to name a
* thread they aren't in — and the BFF re-checks both memberships anyway, since
* a client-supplied conversation id proves nothing.
*/
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { toast } from "sonner";
import { UserAvatar } from "@/components/ui/user-avatar";
import {
chat,
conversationTitle,
groupAvatarUrl,
type ChatMessage,
type InboxItem,
} from "@/lib/chat";
export function ForwardDialog({
message,
onClose,
}: {
message: ChatMessage;
onClose: () => void;
}) {
const [threads, setThreads] = useState<InboxItem[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let cancelled = false;
chat
.inbox("chats")
.then((rows) => {
if (cancelled) return;
setThreads(rows ?? []);
setState("ready");
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const forward = async (to: InboxItem) => {
setBusy(to.id);
try {
await chat.forward(to.id, message.id);
toast.success(`Forwarded to ${conversationTitle(to)}`);
onClose();
} catch {
toast.error("Could not forward the message");
} finally {
setBusy(null);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
<div
className="absolute inset-0"
onClick={onClose}
aria-hidden
/>
<div className="relative z-10 max-h-[70vh] w-full max-w-md overflow-hidden rounded-t-2xl border border-border bg-background sm:rounded-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">Forward to…</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="max-h-[55vh] overflow-y-auto">
{state === "error" ? (
<p className="p-4 text-sm text-destructive">
Could not load your chats.
</p>
) : state === "loading" ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : threads.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
You have no other chats yet.
</p>
) : (
<ul className="divide-y divide-border">
{threads.map((t) => (
<li key={t.id}>
<button
type="button"
disabled={busy !== null}
onClick={() => forward(t)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60 disabled:opacity-60"
>
<UserAvatar
src={
t.kind === "group"
? groupAvatarUrl(t.id, t.avatar_key)
: t.peer?.avatar_url
}
name={conversationTitle(t)}
className="size-9 shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{conversationTitle(t)}
</span>
{busy === t.id && (
<span className="text-xs text-muted-foreground">
Sending…
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
/**
* The group profile's Members tab: who is in the group, their role, and the
* admin controls for changing that.
*
* Which controls appear follows the same rules the BFF enforces — the server is
* the authority, this just avoids offering buttons that would be refused.
*/
import { useState } from "react";
import { Plus, ShieldMinus, ShieldPlus, UserMinus } from "lucide-react";
import { toast } from "sonner";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { AddMembersDialog } from "@/components/chat/add-members-dialog";
import { chat, isGroupAdmin, type ConversationDetail } from "@/lib/chat";
const ROLE_LABEL: Record<string, string> = {
owner: "Owner",
admin: "Admin",
member: "",
};
export function GroupMembersTab({
conversationId,
detail,
selfId,
onChanged,
}: {
conversationId: string;
detail: ConversationDetail;
selfId: string;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const iAmAdmin = isGroupAdmin(detail.my_role);
const iAmOwner = detail.my_role === "owner";
const act = async (handle: string, fn: () => Promise<unknown>, ok: string) => {
setBusy(handle);
try {
await fn();
toast.success(ok);
onChanged();
} catch {
toast.error("That didn't work");
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
{iAmAdmin && (
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
<Plus className="size-4" /> Add members
</Button>
)}
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{detail.participants.map((m) => {
const isSelf = m.id === selfId;
// The owner cannot be demoted or removed by anyone, and only the
// owner may change roles at all.
const canChangeRole = iAmOwner && !isSelf && m.role !== "owner";
// An admin may remove members; only the owner may remove an admin.
const canRemove =
!isSelf &&
m.role !== "owner" &&
(iAmOwner || (iAmAdmin && m.role !== "admin"));
return (
<li key={m.id} className="flex items-center gap-3 px-4 py-3">
<UserAvatar
src={m.avatar_url}
name={m.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{m.name}
{isSelf && (
<span className="text-muted-foreground"> (you)</span>
)}
</p>
<p className="truncate text-xs text-muted-foreground">
@{m.handle}
</p>
</div>
{m.role && ROLE_LABEL[m.role] && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{ROLE_LABEL[m.role]}
</span>
)}
{canChangeRole && (
<button
type="button"
disabled={busy === m.handle}
aria-label={
m.role === "admin" ? "Remove admin" : "Make admin"
}
onClick={() =>
act(
m.handle,
() =>
chat.setMemberRole(
conversationId,
m.handle,
m.role === "admin" ? "member" : "admin",
),
m.role === "admin"
? `${m.name} is no longer an admin`
: `${m.name} is now an admin`,
)
}
className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{m.role === "admin" ? (
<ShieldMinus className="size-4" />
) : (
<ShieldPlus className="size-4" />
)}
</button>
)}
{canRemove && (
<button
type="button"
disabled={busy === m.handle}
aria-label={`Remove ${m.name}`}
onClick={() => {
if (!window.confirm(`Remove ${m.name} from the group?`)) return;
void act(
m.handle,
() => chat.removeMember(conversationId, m.handle),
`${m.name} was removed`,
);
}}
className="shrink-0 rounded-md p-1.5 text-destructive hover:opacity-80 disabled:opacity-40"
>
<UserMinus className="size-4" />
</button>
)}
</li>
);
})}
</ul>
{adding && (
<AddMembersDialog
conversationId={conversationId}
existing={detail.participants.map((p) => p.handle)}
onClose={() => setAdding(false)}
onAdded={onChanged}
/>
)}
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
"use client";
/**
* The group profile's Settings tab: rename, choose who may post, and leave.
*
* Editing is admin-only; everyone else sees the same facts read-only, so the
* tab still answers "why can't I post here?" for a plain member.
*/
import { useRef, useState } from "react";
import { Camera, Loader2, LogOut } from "lucide-react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/ui/user-avatar";
import { compressImage } from "@/lib/compress-image";
import {
chat,
groupAvatarUrl,
isGroupAdmin,
uploadGroupAvatar,
type ConversationDetail,
type SendPolicy,
} from "@/lib/chat";
import { cn } from "@/lib/utils";
export function GroupSettingsTab({
conversationId,
detail,
selfHandle,
onChanged,
}: {
conversationId: string;
detail: ConversationDetail;
selfHandle: string;
onChanged: () => void;
}) {
const router = useRouter();
const [title, setTitle] = useState(detail.title ?? "");
const [busy, setBusy] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const iAmAdmin = isGroupAdmin(detail.my_role);
const renamed = title.trim() !== (detail.title ?? "").trim();
/**
* Upload first, then point the group at the new key. Two steps because the
* bytes go straight to storage and never through the BFF — the same path
* message attachments take.
*/
const changePhoto = async (file: File) => {
setUploading(true);
try {
const compressed = await compressImage(file).catch(() => file);
const key = await uploadGroupAvatar(conversationId, compressed);
await chat.updateGroup(conversationId, { avatar_key: key });
toast.success("Group photo updated");
onChanged();
} catch {
toast.error("Could not update the group photo");
} finally {
setUploading(false);
}
};
const rename = async () => {
if (!renamed || title.trim() === "") return;
setBusy(true);
try {
await chat.updateGroup(conversationId, { title: title.trim() });
toast.success("Group renamed");
onChanged();
} catch {
toast.error("Could not rename the group");
} finally {
setBusy(false);
}
};
const setPolicy = async (policy: SendPolicy) => {
if (policy === detail.send_policy) return;
setBusy(true);
try {
await chat.updateGroup(conversationId, { send_policy: policy });
toast.success(
policy === "admins"
? "Only admins can send messages now"
: "Everyone can send messages now",
);
onChanged();
} catch {
toast.error("Could not change the setting");
} finally {
setBusy(false);
}
};
const leave = async () => {
if (
!window.confirm(
detail.my_role === "owner"
? "Leave this group? Another admin will become the owner."
: "Leave this group?",
)
) {
return;
}
setBusy(true);
try {
await chat.removeMember(conversationId, selfHandle);
toast.success("You left the group");
router.push("/chats");
} catch {
toast.error("Could not leave the group");
setBusy(false);
}
};
return (
<div className="space-y-6">
<section className="space-y-2">
<h3 className="text-sm font-medium text-foreground">Group photo</h3>
<div className="flex items-center gap-3">
<div className="relative">
<UserAvatar
src={groupAvatarUrl(conversationId, detail.avatar_key)}
name={detail.title ?? "Group"}
className="size-16"
textClassName="text-lg"
/>
{uploading && (
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
<Loader2 className="size-5 animate-spin text-white" />
</span>
)}
</div>
{iAmAdmin ? (
<>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void changePhoto(file);
// Reset so re-picking the same file still fires a change.
e.target.value = "";
}}
/>
<Button
variant="outline"
size="sm"
disabled={uploading || busy}
onClick={() => fileRef.current?.click()}
>
<Camera className="size-4" />
{detail.avatar_key ? "Change photo" : "Add photo"}
</Button>
</>
) : (
<p className="text-xs text-muted-foreground">
Only group admins can change the photo.
</p>
)}
</div>
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium text-foreground">Group name</h3>
{iAmAdmin ? (
<div className="flex gap-2">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"
/>
<Button size="sm" disabled={!renamed || busy} onClick={rename}>
Save
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">{detail.title}</p>
)}
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium text-foreground">
Who can send messages
</h3>
<div className="space-y-2">
<PolicyOption
active={detail.send_policy === "everyone"}
disabled={!iAmAdmin || busy}
onClick={() => setPolicy("everyone")}
title="Everyone"
description="Any member can send messages."
/>
<PolicyOption
active={detail.send_policy === "admins"}
disabled={!iAmAdmin || busy}
onClick={() => setPolicy("admins")}
title="Only admins"
description="Members can read but not post — an announcement group."
/>
</div>
{!iAmAdmin && (
<p className="text-xs text-muted-foreground">
Only group admins can change these settings.
</p>
)}
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium text-foreground">Leave</h3>
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={leave}
>
<LogOut className="size-4" /> Leave group
</Button>
{detail.my_role === "owner" && (
<p className="text-xs text-muted-foreground">
You own this group. If you leave, the longest-serving admin becomes
the owner.
</p>
)}
</section>
</div>
);
}
function PolicyOption({
active,
disabled,
onClick,
title,
description,
}: {
active: boolean;
disabled: boolean;
onClick: () => void;
title: string;
description: string;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-pressed={active}
className={cn(
"flex w-full flex-col items-start gap-0.5 rounded-lg border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/60",
disabled && "cursor-default opacity-70 hover:bg-transparent",
)}
>
<span className="text-sm font-medium text-foreground">{title}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</button>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
/**
* The link card shown under a message, and in the composer before sending.
*
* Everything rendered here was fetched by the BFF. A client can choose whether
* a card appears, never what it says — otherwise any link could be dressed up
* as any site.
*/
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { LinkPreview } from "@/lib/chat";
export function LinkPreviewCard({
preview,
onDismiss,
compact = false,
className,
}: {
preview: LinkPreview;
/** When set, a dismiss control is shown (composer only). */
onDismiss?: () => void;
compact?: boolean;
className?: string;
}) {
const body = (
<div className="flex min-w-0 gap-2">
{preview.image_url && (
// Remote preview images come from arbitrary hosts, which next/image
// would need configured per-domain.
// eslint-disable-next-line @next/next/no-img-element
<img
src={preview.image_url}
alt=""
loading="lazy"
className={cn(
"shrink-0 rounded object-cover",
compact ? "size-10" : "size-14",
)}
/>
)}
<div className="min-w-0 flex-1">
{preview.site_name && (
<p className="truncate text-[11px] uppercase tracking-wide opacity-70">
{preview.site_name}
</p>
)}
<p className="line-clamp-2 text-xs font-medium">
{preview.title || preview.url}
</p>
{!compact && preview.description && (
<p className="line-clamp-2 text-[11px] opacity-80">
{preview.description}
</p>
)}
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="Remove link preview"
className="shrink-0 self-start rounded-md p-1 opacity-70 hover:opacity-100"
>
<X className="size-3.5" />
</button>
)}
</div>
);
const shell = cn(
"rounded-lg border-l-2 border-current/30 bg-black/5 px-2 py-1.5 dark:bg-white/10",
className,
);
// In the composer the card is being edited, so it isn't a link; under a
// message it is.
if (onDismiss) return <div className={shell}>{body}</div>;
return (
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className={cn(shell, "block hover:opacity-90")}
>
{body}
</a>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
/**
* The media tiles inside a bubble.
*
* One item fills the bubble; several tile into a 2×2. Beyond four, the fourth
* tile carries a "+N more" overlay rather than growing the bubble — a message
* with twenty photos should still be one glanceable block.
*
* Every tile is square so the grid stays even regardless of what was sent;
* portrait and landscape both crop to the same cell.
*/
import { Play } from "lucide-react";
import { attachmentUrl, type Attachment } from "@/lib/chat";
/** Tiles shown before the rest collapse into the "+N more" overlay. */
const MAX_TILES = 4;
export function MediaGrid({
items,
onOpen,
}: {
items: Attachment[];
/** Opens the lightbox at this index. */
onOpen: (index: number) => void;
}) {
if (items.length === 0) return null;
const visible = items.slice(0, MAX_TILES);
const hidden = items.length - visible.length;
if (items.length === 1) {
return (
<button
type="button"
onClick={() => onOpen(0)}
className="block overflow-hidden rounded-lg"
>
<Tile item={items[0]} className="max-h-72 w-auto" />
</button>
);
}
return (
// Two columns throughout: two items make a row, three or four make the
// 2×2. A three-item grid deliberately leaves the fourth cell empty rather
// than stretching one tile — an uneven crop reads worse than a gap.
<div className="grid w-56 grid-cols-2 gap-0.5 overflow-hidden rounded-lg sm:w-64">
{visible.map((item, i) => {
const isLast = i === visible.length - 1;
return (
<button
key={item.id}
type="button"
onClick={() => onOpen(i)}
className="relative aspect-square overflow-hidden"
>
<Tile item={item} className="size-full object-cover" />
{isLast && hidden > 0 && (
<span className="absolute inset-0 flex items-center justify-center bg-black/60 text-sm font-medium text-white">
+{hidden} more
</span>
)}
</button>
);
})}
</div>
);
}
function Tile({ item, className }: { item: Attachment; className?: string }) {
if (item.kind === "video") {
return (
<span className="relative block size-full">
<video
src={attachmentUrl(item.id)}
// Just the poster frame: the lightbox is where it plays.
preload="metadata"
muted
playsInline
className={className}
/>
<span className="absolute inset-0 flex items-center justify-center">
<span className="rounded-full bg-black/50 p-2">
<Play className="size-4 fill-white text-white" />
</span>
</span>
</span>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachmentUrl(item.id)}
alt={item.file_name ?? "Shared media"}
loading="lazy"
width={item.width ?? undefined}
height={item.height ?? undefined}
className={className}
/>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
/**
* Full-screen viewer for a message's media.
*
* A horizontally scroll-snapped carousel rather than a slide animation: swipe
* comes free on touch, the scrollbar reflects position, and the browser handles
* momentum. Arrow keys and the on-screen buttons scroll to a slide rather than
* setting state, so every input path drives the same mechanism.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight, Download, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { attachmentUrl, type Attachment } from "@/lib/chat";
import { cn } from "@/lib/utils";
export function MediaLightbox({
items,
startIndex = 0,
onClose,
}: {
items: Attachment[];
startIndex?: number;
onClose: () => void;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(startIndex);
const [downloading, setDownloading] = useState(false);
const scrollTo = useCallback((i: number, smooth = true) => {
const track = trackRef.current;
if (!track) return;
track.scrollTo({
left: i * track.clientWidth,
behavior: smooth ? "smooth" : "auto",
});
}, []);
// Jump to the tapped item before the first paint, so opening the third photo
// doesn't visibly scroll from the first.
useEffect(() => {
scrollTo(startIndex, false);
}, [startIndex, scrollTo]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowRight") scrollTo(Math.min(index + 1, items.length - 1));
if (e.key === "ArrowLeft") scrollTo(Math.max(index - 1, 0));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [index, items.length, onClose, scrollTo]);
const current = items[index];
/**
* Download through a blob so the file keeps its name. The `download`
* attribute is ignored cross-origin, and chat media is served from the BFF,
* so a plain link would just navigate. If the fetch is refused (the signed
* storage URL needs CORS), fall back to opening it — better than nothing.
*/
const download = async () => {
if (!current) return;
setDownloading(true);
const href = attachmentUrl(current.id);
try {
const res = await fetch(href, { credentials: "include" });
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = current.file_name ?? `attachment-${current.id}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(href, "_blank", "noopener,noreferrer");
toast.message("Opened in a new tab");
} finally {
setDownloading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-black/95">
<div className="flex items-center justify-between gap-2 px-3 py-2 text-white">
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md p-2 hover:bg-white/10"
>
<X className="size-5" />
</button>
{items.length > 1 && (
<span className="text-xs tabular-nums text-white/70">
{index + 1} / {items.length}
</span>
)}
<button
type="button"
onClick={download}
disabled={downloading}
aria-label="Download"
className="rounded-md p-2 hover:bg-white/10 disabled:opacity-50"
>
{downloading ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Download className="size-5" />
)}
</button>
</div>
<div className="relative flex-1 overflow-hidden">
<div
ref={trackRef}
onScroll={(e) => {
const el = e.currentTarget;
const next = Math.round(el.scrollLeft / el.clientWidth);
if (next !== index) setIndex(next);
}}
className="flex h-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{items.map((a) => (
<div
key={a.id}
className="flex h-full w-full shrink-0 snap-center items-center justify-center p-4"
>
{a.kind === "video" ? (
<video
src={attachmentUrl(a.id)}
controls
playsInline
className="max-h-full max-w-full"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachmentUrl(a.id)}
alt={a.file_name ?? "Shared media"}
className="max-h-full max-w-full object-contain"
/>
)}
</div>
))}
</div>
{items.length > 1 && (
<>
<Arrow
side="left"
disabled={index === 0}
onClick={() => scrollTo(index - 1)}
/>
<Arrow
side="right"
disabled={index === items.length - 1}
onClick={() => scrollTo(index + 1)}
/>
</>
)}
</div>
</div>
);
}
function Arrow({
side,
disabled,
onClick,
}: {
side: "left" | "right";
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={side === "left" ? "Previous" : "Next"}
className={cn(
// Pointer devices only: on touch the swipe is the gesture, and arrows
// would sit over the image for no reason.
"absolute top-1/2 hidden -translate-y-1/2 rounded-full bg-white/10 p-2 text-white",
"hover:bg-white/20 disabled:opacity-0 sm:block",
side === "left" ? "left-3" : "right-3",
)}
>
{side === "left" ? (
<ChevronLeft className="size-5" />
) : (
<ChevronRight className="size-5" />
)}
</button>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
/**
* Attachments under a message bubble.
*
* Media (images and videos) tile into a grid and open in a lightbox; links and
* files keep their own row treatments, since a link card and a photo are read
* very differently.
*
* Media is fetched through the BFF's stable /chat/attachments/{id} URL, which
* re-checks membership and redirects to a short-lived signed URL. The object
* itself lives in a private bucket and is never public.
*/
import { useState } from "react";
import { FileText } from "lucide-react";
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
import { MediaGrid } from "@/components/chat/media-grid";
import { MediaLightbox } from "@/components/chat/media-lightbox";
import { attachmentUrl, type Attachment } from "@/lib/chat";
function humanSize(bytes?: number | null): string {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let n = bytes;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
}
export function MessageAttachments({
attachments,
}: {
attachments: Attachment[];
}) {
const [lightboxAt, setLightboxAt] = useState<number | null>(null);
if (!attachments || attachments.length === 0) return null;
// Only images and videos tile; everything else keeps its own row.
const media = attachments.filter(
(a) => a.kind === "image" || a.kind === "video",
);
const rest = attachments.filter(
(a) => a.kind !== "image" && a.kind !== "video",
);
return (
<>
<div className="mt-1 flex flex-col gap-1.5">
{media.length > 0 && (
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
)}
{rest.map((a) => {
switch (a.kind) {
case "link":
return (
<LinkPreviewCard
key={a.id}
compact
preview={{
url: a.url ?? "",
title: a.title ?? undefined,
description: a.description ?? undefined,
site_name: a.site_name ?? undefined,
image_url: a.image_url ?? undefined,
}}
/>
);
case "audio":
return (
<audio
key={a.id}
src={attachmentUrl(a.id)}
controls
className="w-56"
/>
);
default:
return (
<a
key={a.id}
href={attachmentUrl(a.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-xs hover:opacity-90 dark:bg-white/10"
>
<FileText className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">
{a.file_name ?? "Attachment"}
</span>
{a.size_bytes ? (
<span className="shrink-0 opacity-70">
{humanSize(a.size_bytes)}
</span>
) : null}
</a>
);
}
})}
</div>
{lightboxAt !== null && (
<MediaLightbox
items={media}
startIndex={lightboxAt}
onClose={() => setLightboxAt(null)}
/>
)}
</>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
/**
* Owns every pointer gesture on a chat bubble: swipe right to reply, and
* long-press (or right-click) to open the context menu.
*
* They live in ONE component because they share a pointer stream and must
* arbitrate between themselves. Two independent listeners would both claim the
* same press — you would get a menu in the middle of a swipe, or a swipe that
* never fires because the menu opened first.
*
* Swipe-to-reply behaves as before:
*
* Details that make the gesture feel right rather than fight the page:
*
* • Only horizontal drags are captured. A gesture that starts out mostly
* vertical is released back to the scroller immediately, so swiping never
* steals a scroll.
* • Rightward only, with resistance past the trigger point, so the bubble
* can't be dragged across the screen.
* • The reply fires on release, not on crossing the threshold, so a drag can
* still be cancelled by sliding back.
*
* Pointer Events cover touch and mouse, so the same code gives desktop a
* click-drag; the context menu in a later phase adds a non-gesture route.
*/
import { useCallback, useRef, useState } from "react";
import { Reply } from "lucide-react";
import { cn } from "@/lib/utils";
/** Drag distance that arms the reply. */
const TRIGGER_PX = 56;
/** Hard stop, so the bubble never travels far. */
const MAX_PX = 80;
/** Horizontal movement needed before we claim the gesture from the scroller. */
const CLAIM_PX = 10;
/** Hold time before a stationary press becomes a context menu. */
const LONG_PRESS_MS = 450;
/** Movement that cancels a long press — a drag is not a hold. */
const LONG_PRESS_SLOP_PX = 8;
export function MessageGestures({
onReply,
onMenu,
disabled = false,
children,
}: {
onReply: () => void;
/** Opens the context menu at the given viewport point. */
onMenu: (point: { x: number; y: number }) => void;
disabled?: boolean;
children: React.ReactNode;
}) {
const [offset, setOffset] = useState(0);
const [dragging, setDragging] = useState(false);
const start = useRef<{ x: number; y: number } | null>(null);
// null = undecided, true = ours, false = the scroller's
const claimed = useRef<boolean | null>(null);
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set once a long press fires, so the release doesn't also trigger a reply.
const consumed = useRef(false);
const cancelHold = useCallback(() => {
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
}, []);
const reset = useCallback(() => {
cancelHold();
start.current = null;
claimed.current = null;
setDragging(false);
setOffset(0);
}, [cancelHold]);
const onPointerDown = (e: React.PointerEvent) => {
if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return;
start.current = { x: e.clientX, y: e.clientY };
claimed.current = null;
consumed.current = false;
// A stationary hold opens the menu. Touch only: a mouse has right-click,
// and making a held left-click open a menu would surprise desktop users.
if (e.pointerType !== "mouse") {
const point = { x: e.clientX, y: e.clientY };
cancelHold();
holdTimer.current = setTimeout(() => {
consumed.current = true;
reset();
onMenu(point);
}, LONG_PRESS_MS);
}
};
const onPointerMove = (e: React.PointerEvent) => {
if (!start.current || disabled) return;
const dx = e.clientX - start.current.x;
const dy = e.clientY - start.current.y;
// Any real movement means this is a drag, not a hold.
if (Math.abs(dx) > LONG_PRESS_SLOP_PX || Math.abs(dy) > LONG_PRESS_SLOP_PX) {
cancelHold();
}
if (claimed.current === null) {
if (Math.abs(dx) < CLAIM_PX && Math.abs(dy) < CLAIM_PX) return;
// Vertical intent, or a leftward drag: hand it back to the scroller and
// stay out of the way for the rest of this gesture.
claimed.current = Math.abs(dx) > Math.abs(dy) && dx > 0;
if (!claimed.current) {
start.current = null;
return;
}
e.currentTarget.setPointerCapture?.(e.pointerId);
setDragging(true);
}
// Resistance past the trigger point.
const travel = dx <= TRIGGER_PX ? dx : TRIGGER_PX + (dx - TRIGGER_PX) * 0.25;
setOffset(Math.max(0, Math.min(travel, MAX_PX)));
};
const onPointerUp = () => {
cancelHold();
// A long press already acted; releasing must not also fire a reply.
if (!consumed.current && claimed.current && offset >= TRIGGER_PX) onReply();
reset();
};
const armed = offset >= TRIGGER_PX;
return (
<div
className="relative"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={reset}
onContextMenu={(e) => {
if (disabled) return;
e.preventDefault();
cancelHold();
onMenu({ x: e.clientX, y: e.clientY });
}}
// Let the browser own vertical panning; we only ever take horizontal.
style={{ touchAction: "pan-y" }}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-y-0 left-0 flex items-center transition-opacity",
offset > 8 ? "opacity-100" : "opacity-0",
)}
>
<span
className={cn(
"inline-flex size-7 items-center justify-center rounded-full transition-colors",
armed ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
>
<Reply className="size-3.5" />
</span>
</span>
<div
style={{ transform: `translateX(${offset}px)` }}
className={cn(!dragging && "transition-transform duration-200")}
>
{children}
</div>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
/**
* One stack of consecutive messages from a single sender, rendered the way
* Instagram does: the avatar appears once beside the last bubble, the corners
* tighten between stacked bubbles, and one timestamp closes the stack.
*/
import { Clock, Reply, RotateCcw, Star } from "lucide-react";
import { UserAvatar } from "@/components/ui/user-avatar";
import { MessageGestures } from "@/components/chat/message-gestures";
import { MessageText } from "@/components/chat/message-text";
import { MessageAttachments } from "@/components/chat/message-attachments";
import { MessageTicks } from "@/components/chat/message-ticks";
import { tickState } from "@/lib/chat-format";
import { cn } from "@/lib/utils";
import type { ChatMessage, ChatPeer, Receipts, ReplyPreview } from "@/lib/chat";
/** Time only — the day is already established by the date separator above. */
function clockTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
function bubbleCorners(mine: boolean, index: number, total: number): string {
if (total === 1) return "rounded-2xl";
const first = index === 0;
const last = index === total - 1;
// Only the edge nearest the sender tightens, so the stack reads as one unit.
if (mine) {
return cn(
"rounded-2xl",
!first && "rounded-tr-md",
!last && "rounded-br-md",
);
}
return cn("rounded-2xl", !first && "rounded-tl-md", !last && "rounded-bl-md");
}
/**
* The quoted line above a reply. Kept to one line: it is a pointer back to the
* original, not a second copy of it.
*/
function QuotedReply({
reply,
mine,
selfId,
nameFor,
}: {
reply: ReplyPreview;
mine: boolean;
selfId: string;
nameFor: (senderId: string | null | undefined) => string;
}) {
// The server hydrates sender_name, but it is empty for messages no user
// wrote (Kautilya, system notices) — fall back to the participant lookup so
// those quote as something meaningful rather than "Unknown".
const who =
reply.sender_id === selfId
? "You"
: reply.sender_name || nameFor(reply.sender_id);
return (
<div
className={cn(
"mb-1 rounded-lg border-l-2 px-2 py-1 text-xs",
mine
? "border-primary-foreground/50 bg-black/10 text-primary-foreground/90 dark:text-white/90"
: "border-primary/50 bg-background/60 text-muted-foreground",
)}
>
<span className="block font-medium">{who}</span>
<span className="line-clamp-1 break-words">
{reply.deleted ? "This message was deleted" : reply.body}
</span>
</div>
);
}
export function MessageGroup({
messages,
mine,
peer,
receipts,
showAvatar,
selfId,
onReply,
onMenu,
starredIds,
onRetry,
nameFor,
}: {
messages: ChatMessage[];
mine: boolean;
peer?: ChatPeer | null;
receipts: Receipts | null;
/** Groups show an avatar; a DM's own side never does. */
showAvatar: boolean;
selfId: string;
onReply: (message: ChatMessage) => void;
/** Opens the context menu for a message at a viewport point. */
onMenu: (message: ChatMessage, point: { x: number; y: number }) => void;
/** Ids the caller has starred, so the bubble can show its star. */
starredIds: Set<string>;
/** Re-send a message whose delivery failed. */
onRetry: (message: ChatMessage) => void;
/** Names a quoted sender; supplied by the thread's participant list. */
nameFor: (senderId: string | null | undefined) => string;
}) {
const last = messages[messages.length - 1];
return (
<div
className={cn(
"flex w-full items-end gap-2",
mine ? "justify-end" : "justify-start",
)}
>
{!mine && showAvatar && (
<UserAvatar
src={peer?.avatar_url}
name={peer?.name ?? "?"}
className="size-7 shrink-0"
textClassName="text-[10px]"
/>
)}
<div
className={cn(
"flex max-w-[78%] flex-col gap-0.5",
mine ? "items-end" : "items-start",
!mine && !showAvatar && "ml-9",
)}
>
{messages.map((m, i) => (
// A deleted message has nothing to quote, so it isn't swipeable.
<MessageGestures
key={m.id}
disabled={!!m.deleted_at || !!m.pending}
onReply={() => onReply(m)}
onMenu={(point) => onMenu(m, point)}
>
<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",
)}
>
{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"
/>
)}
</>
)}
{/* 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>
)}
</div>
</MessageGestures>
))}
<div className="flex items-center gap-1 px-1">
<time
dateTime={last.created_at}
className="text-[11px] text-muted-foreground"
>
{clockTime(last.created_at)}
</time>
{/* Ticks belong only to your own messages — you can't know whether
someone else's message reached you before it did. A message still
in flight has earned no tick at all. */}
{mine && last.failed && (
<button
type="button"
onClick={() => onRetry(last)}
className="inline-flex items-center gap-1 text-[11px] font-medium text-destructive hover:underline"
>
<RotateCcw className="size-3" />
Not sent — retry
</button>
)}
{mine && !last.deleted_at && !last.failed && (
last.pending ? (
<Clock
role="img"
aria-label="Sending"
className="size-3.5 shrink-0 text-muted-foreground"
/>
) : (
<MessageTicks state={tickState(last.seq, receipts)} />
)
)}
</div>
</div>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
/**
* The scrolling thread: date separators, grouped bubbles, and infinite scroll
* upwards into older history.
*
* Two scroll behaviours matter and are easy to get wrong:
*
* 1. Loading older messages PREPENDS content, which would otherwise shove the
* reader down the page. We record scrollHeight before the prepend and add
* the difference back afterwards, so the message they were reading stays
* under their eyes.
*
* 2. A new message should only auto-scroll if they were already at the
* bottom. Yanking someone away from history they are reading is worse than
* making them tap to catch up.
*/
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { groupMessages } from "@/lib/chat-grouping";
import { MessageGroup } from "@/components/chat/message-group";
import type { ChatMessage, ChatPeer, Receipts } from "@/lib/chat";
/** Distance from the bottom still considered "at the bottom". */
const AT_BOTTOM_PX = 120;
/** Distance from the top that triggers loading older history. */
const LOAD_MORE_PX = 200;
function dayLabel(iso: string): string {
const d = new Date(iso);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString();
if (sameDay(d, today)) return "Today";
if (sameDay(d, yesterday)) return "Yesterday";
return d.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: d.getFullYear() === today.getFullYear() ? undefined : "numeric",
});
}
export function MessageList({
messages,
selfId,
peer,
receipts,
isGroup,
hasMore,
loadingMore,
onLoadMore,
onReply,
onMenu,
starredIds,
onRetry,
nameFor,
}: {
/** Ascending by seq — oldest first. */
messages: ChatMessage[];
selfId: string;
peer?: ChatPeer | null;
receipts: Receipts | null;
isGroup: boolean;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
onReply: (message: ChatMessage) => void;
onMenu: (message: ChatMessage, point: { x: number; y: number }) => void;
starredIds: Set<string>;
onRetry: (message: ChatMessage) => void;
nameFor: (senderId: string | null | undefined) => string;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomRef = useRef(true);
// Height captured just before older messages are prepended.
const prependAnchorRef = useRef<number | null>(null);
const prevOldestRef = useRef<string | null>(null);
const prevNewestRef = useRef<string | null>(null);
const oldest = messages[0]?.id ?? null;
const newest = messages[messages.length - 1]?.id ?? null;
const onScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
atBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < AT_BOTTOM_PX;
if (el.scrollTop < LOAD_MORE_PX && hasMore && !loadingMore) {
// Capture the anchor before the parent prepends, so the layout effect
// below can restore the reading position.
prependAnchorRef.current = el.scrollHeight;
onLoadMore();
}
}, [hasMore, loadingMore, onLoadMore]);
// Runs before paint, so the correction is never visible as a jump.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
const prependedOlder =
prevOldestRef.current !== null && oldest !== prevOldestRef.current;
const appendedNewer =
prevNewestRef.current !== null && newest !== prevNewestRef.current;
const firstRender = prevNewestRef.current === null && newest !== null;
if (prependedOlder && prependAnchorRef.current !== null) {
el.scrollTop += el.scrollHeight - prependAnchorRef.current;
prependAnchorRef.current = null;
} else if (firstRender) {
el.scrollTop = el.scrollHeight; // open at the newest message
} else if (appendedNewer && atBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
prevOldestRef.current = oldest;
prevNewestRef.current = newest;
}, [oldest, newest, messages.length]);
// Keep pulling while the thread is still shorter than its viewport —
// otherwise a short first page leaves nothing to scroll and history stalls.
useEffect(() => {
const el = scrollRef.current;
if (!el || loadingMore || !hasMore) return;
if (el.scrollHeight <= el.clientHeight) onLoadMore();
}, [messages.length, loadingMore, hasMore, onLoadMore]);
const rows = groupMessages(messages);
return (
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4"
>
{loadingMore && (
<p className="pb-3 text-center text-xs text-muted-foreground">
Loading earlier messages…
</p>
)}
<div className="flex flex-col gap-3">
{rows.map((row) =>
row.kind === "date" ? (
<div key={row.key} className="flex justify-center py-1">
<span className="rounded-full bg-muted px-3 py-1 text-[11px] font-medium text-muted-foreground">
{dayLabel(row.at)}
</span>
</div>
) : row.messages[0]?.kind === "system" ? (
<div key={row.key} className="flex justify-center">
<span className="max-w-[80%] text-center text-[11px] text-muted-foreground">
{row.messages[0].body}
</span>
</div>
) : (
<MessageGroup
key={row.key}
messages={row.messages}
mine={row.senderId === selfId}
peer={peer}
receipts={receipts}
showAvatar={isGroup}
selfId={selfId}
onReply={onReply}
onMenu={onMenu}
starredIds={starredIds}
onRetry={onRetry}
nameFor={nameFor}
/>
),
)}
</div>
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
/**
* The message context menu: reply, copy, star, forward, delete, report.
*
* Opened by long-press on touch and right-click on desktop, both arbitrated by
* MessageGestures. Positioned at the pointer and clamped to the viewport, so a
* bubble near an edge doesn't push the menu off-screen.
*/
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
Copy,
Flag,
Forward,
Reply,
Star,
StarOff,
Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type MenuPoint = { x: number; y: number };
export type MessageMenuActions = {
onReply: () => void;
onCopy: () => void;
onStar: () => void;
onForward: () => void;
onDeleteForMe: () => void;
/** Absent when the caller isn't the sender — only they may erase for all. */
onDeleteForEveryone?: () => void;
onReport?: () => void;
};
const MARGIN = 8;
export function MessageMenu({
point,
starred,
actions,
onClose,
}: {
point: MenuPoint;
starred: boolean;
actions: MessageMenuActions;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<MenuPoint>(point);
// Clamp after measuring, before paint, so the menu never flashes off-screen.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const { width, height } = el.getBoundingClientRect();
setPos({
x: Math.min(Math.max(MARGIN, point.x), window.innerWidth - width - MARGIN),
y: Math.min(Math.max(MARGIN, point.y), window.innerHeight - height - MARGIN),
});
}, [point.x, point.y]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const run = (fn?: () => void) => () => {
fn?.();
onClose();
};
return (
<>
{/* Catches the next click or scroll anywhere and dismisses. */}
<div
className="fixed inset-0 z-40"
onClick={onClose}
onContextMenu={(e) => {
e.preventDefault();
onClose();
}}
/>
<div
ref={ref}
role="menu"
style={{ left: pos.x, top: pos.y }}
// min-w-56 rather than something tighter: "Delete for everyone" is the
// longest label and, with its icon and padding, was wrapping or sitting
// flush against the edge at a narrower width.
className="fixed z-50 min-w-56 overflow-hidden rounded-xl border border-border bg-background py-1 shadow-lg"
>
<Item icon={<Reply className="size-4" />} onClick={run(actions.onReply)}>
Reply
</Item>
<Item icon={<Copy className="size-4" />} onClick={run(actions.onCopy)}>
Copy
</Item>
<Item
icon={
starred ? <StarOff className="size-4" /> : <Star className="size-4" />
}
onClick={run(actions.onStar)}
>
{starred ? "Remove star" : "Star"}
</Item>
<Item
icon={<Forward className="size-4" />}
onClick={run(actions.onForward)}
>
Forward
</Item>
<div className="my-1 h-px bg-border" />
<Item
icon={<Trash2 className="size-4" />}
onClick={run(actions.onDeleteForMe)}
>
Delete for me
</Item>
{actions.onDeleteForEveryone && (
<Item
danger
icon={<Trash2 className="size-4" />}
onClick={run(actions.onDeleteForEveryone)}
>
Delete for everyone
</Item>
)}
{actions.onReport && (
<Item
danger
icon={<Flag className="size-4" />}
onClick={run(actions.onReport)}
>
Report
</Item>
)}
</div>
</>
);
}
function Item({
icon,
onClick,
danger = false,
children,
}: {
icon: React.ReactNode;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
danger
? "text-destructive hover:bg-destructive/10"
: "text-foreground hover:bg-muted",
)}
>
{icon}
{children}
</button>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
/**
* Renders a message body from its entity spans.
*
* Nothing here parses markup — the server already turned the composer's
* markers into clean text plus spans, so a message cannot inject markup into
* anyone else's thread. This component only decorates ranges.
*/
import { buildEntityTree, type EntityNode } from "@/lib/chat-format";
import type { MessageEntity } from "@/lib/chat";
function renderNodes(nodes: EntityNode[], keyPrefix: string): React.ReactNode[] {
return nodes.map((node, i) => {
const key = `${keyPrefix}-${i}`;
if (node.kind === "text") return <span key={key}>{node.text}</span>;
const children = renderNodes(node.children, key);
switch (node.entity.type) {
case "bold":
return <strong key={key} className="font-semibold">{children}</strong>;
case "strike":
return <s key={key}>{children}</s>;
case "code":
return (
<code
key={key}
className="rounded bg-black/10 px-1 py-0.5 font-mono text-[0.9em] dark:bg-white/15"
>
{children}
</code>
);
case "url":
return (
<a
key={key}
href={node.entity.url}
target="_blank"
// noreferrer as well as noopener: the destination shouldn't learn
// which conversation the link was shared in.
rel="noopener noreferrer"
className="underline underline-offset-2 hover:opacity-80"
>
{children}
</a>
);
default:
return <span key={key}>{children}</span>;
}
});
}
export function MessageText({
body,
entities,
}: {
body: string;
entities: MessageEntity[];
}) {
// whitespace-pre-wrap keeps the sender's line breaks without turning the
// body into markup.
return (
<span className="whitespace-pre-wrap break-words">
{renderNodes(buildEntityTree(body, entities), "e")}
</span>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
/**
* Delivery ticks for your own messages: one for sent, two for delivered, two
* in blue for read.
*
* "Read" means read by everyone — the cursors behind it are the minimum across
* the other participants, so a group only turns blue once the last member has
* caught up.
*/
import { Check, CheckCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TickState } from "@/lib/chat-format";
const LABEL: Record<TickState, string> = {
sent: "Sent",
delivered: "Delivered",
read: "Read by everyone",
};
export function MessageTicks({
state,
className,
}: {
state: TickState;
className?: string;
}) {
const Icon = state === "sent" ? Check : CheckCheck;
return (
<Icon
role="img"
aria-label={LABEL[state]}
className={cn(
"size-3.5 shrink-0",
state === "read" ? "text-sky-500" : "text-muted-foreground",
className,
)}
/>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
/**
* Create a group: name it, pick people, done.
*
* Members are named by handle, matching the member-management API — search
* results already identify people that way, so nothing needs id plumbing.
*/
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Search, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { chat } from "@/lib/chat";
type Candidate = { handle: string; name: string; avatar_url: string | null };
export function NewGroupDialog({ onClose }: { onClose: () => void }) {
const router = useRouter();
const { user } = useAuth();
const [title, setTitle] = useState("");
const [query, setQuery] = useState("");
// Results are stored with the query they belong to, so what's displayed can
// be derived: a stale page from a previous query simply stops matching, and
// nothing has to be cleared from an effect.
const [results, setResults] = useState<{ q: string; rows: Candidate[] }>({
q: "",
rows: [],
});
const [picked, setPicked] = useState<Map<string, Candidate>>(new Map());
const [busy, setBusy] = useState(false);
const q = query.trim();
const searchable = q.length >= 2;
// Never offer yourself: you're the group's creator, and the BFF drops the
// caller from the member list anyway — so picking yourself would look like it
// worked and then silently do nothing.
const visible =
results.q === q
? results.rows.filter((u) => u.handle !== user?.handle)
: [];
useEffect(() => {
if (!searchable) return;
const timer = setTimeout(() => {
fetch(`/api/search/users?q=${encodeURIComponent(q)}&limit=15`)
.then((r) => r.json())
.then((d: { results?: Candidate[] }) =>
setResults({ q, rows: d.results ?? [] }),
)
.catch(() => setResults({ q, rows: [] }));
}, 300);
return () => clearTimeout(timer);
}, [q, searchable]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const toggle = (u: Candidate) =>
setPicked((prev) => {
const next = new Map(prev);
if (next.has(u.handle)) next.delete(u.handle);
else next.set(u.handle, u);
return next;
});
const create = async () => {
const name = title.trim();
if (!name) return;
setBusy(true);
try {
const conv = await chat.createGroup(name, [...picked.keys()]);
toast.success("Group created");
router.push(`/chats/${conv.id}`);
} catch {
toast.error("Could not create the group");
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
<div className="absolute inset-0" onClick={onClose} aria-hidden />
<div className="relative z-10 flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-t-2xl border border-border bg-background sm:rounded-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">New group</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="space-y-3 border-b border-border p-3">
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
placeholder="Group name"
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"
/>
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Add people"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
{picked.size > 0 && (
<div className="flex flex-wrap gap-1.5">
{[...picked.values()].map((u) => (
<button
key={u.handle}
type="button"
onClick={() => toggle(u)}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-1 text-xs text-primary"
>
{u.name}
<X className="size-3" />
</button>
))}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
{!searchable ? (
<p className="p-4 text-sm text-muted-foreground">
Search to add people. You can also add them later.
</p>
) : visible.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
) : (
<ul className="divide-y divide-border">
{visible.map((u) => (
<li key={u.handle}>
<button
type="button"
onClick={() => toggle(u)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
>
<UserAvatar
src={u.avatar_url}
name={u.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{u.name}</p>
<p className="truncate text-xs text-muted-foreground">
@{u.handle}
</p>
</div>
{picked.has(u.handle) && (
<Check className="size-4 shrink-0 text-primary" />
)}
</button>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-border p-3">
<Button
className="w-full"
disabled={title.trim() === "" || busy}
onClick={create}
>
Create group
</Button>
</div>
</div>
</div>
);
}
+8 -3
View File
@@ -89,8 +89,13 @@ export function NewUsersStrip({ users }: { users: UserSummary[] }) {
</div> </div>
</div> </div>
{/* Bleed to the section edges (-mx-5) but keep a px-5 gutter so the {/* Bleed to the page edges, with a matching inner gutter so the first and
first/last tiles have breathing room as they scroll past. */} last tiles still have breathing room as they scroll past.
The negative margin MUST match the feed container's horizontal padding
(`main` is `px-4 sm:px-6`). It was `-mx-5` (20px) against a 16px
mobile padding, so the row hung 4px past each edge and gave the whole
document a horizontal scrollbar. Keep these three in lockstep with the
page padding if it ever changes. */}
<div <div
ref={scrollRef} ref={scrollRef}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
@@ -98,7 +103,7 @@ export function NewUsersStrip({ users }: { users: UserSummary[] }) {
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerCancel={onPointerUp} onPointerCancel={onPointerUp}
onClickCapture={onClickCapture} onClickCapture={onClickCapture}
className="-mx-5 flex gap-3 overflow-x-auto px-5 py-1 snap-x scroll-px-5 cursor-grab select-none active:cursor-grabbing [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" className="-mx-4 sm:-mx-6 flex gap-3 overflow-x-auto px-4 sm:px-6 py-1 snap-x scroll-px-4 sm:scroll-px-6 cursor-grab select-none active:cursor-grabbing [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
> >
{users.map((u) => ( {users.map((u) => (
<Link <Link
+4 -5
View File
@@ -13,9 +13,8 @@
import Image from "next/image"; 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, Search, User as UserIcon } from "lucide-react"; import { Home, MessageCircle, Search, User as UserIcon } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Item = { type Item = {
@@ -52,9 +51,9 @@ export function SiteBottomNav() {
], ],
}, },
{ {
href: "/ask", href: "/chat",
label: "Kautilya", label: "Chat",
icon: <KautilyaIcon className="size-5" />, icon: <MessageCircle className="size-5" />,
}, },
{ {
href: user ? "/u/me" : "/login", href: user ? "/u/me" : "/login",
+4 -3
View File
@@ -34,6 +34,7 @@ import {
DownloadCloud, DownloadCloud,
FolderClosed, FolderClosed,
Home, Home,
MessageCircle,
LogIn, LogIn,
Search, Search,
Settings2, Settings2,
@@ -77,9 +78,9 @@ const TOP_ITEMS: NavItem[] = [
], ],
}, },
{ {
href: "/ask", href: "/chats",
label: "Kautilya", label: "Chats",
icon: <KautilyaIcon className="size-5" />, icon: <MessageCircle className="size-5" />,
}, },
{ {
href: "/u/me/collections", href: "/u/me/collections",
@@ -26,7 +26,15 @@ import {
pushSupported, pushSupported,
} from "@/lib/push"; } from "@/lib/push";
const EVENTS: { key: PrefEvent; label: string; description: string }[] = [ const EVENTS: {
key: PrefEvent;
label: string;
description: string;
/** Channels this event can actually be delivered on. Omit for all three.
* A cell outside this set renders as "—" rather than a live toggle, so we
* never show a switch that silently does nothing. */
channels?: PrefChannel[];
}[] = [
{ {
key: "follows", key: "follows",
label: "Follows", label: "Follows",
@@ -42,6 +50,14 @@ const EVENTS: { key: PrefEvent; label: string; description: string }[] = [
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: "community",
label: "Community",
description:
"Rare announcements — such as new feature, downtime etc.",
// Announcements are in-app/push only; we deliberately don't email them.
channels: ["in_app", "push"],
},
]; ];
const CHANNELS: { key: PrefChannel; label: string }[] = [ const CHANNELS: { key: PrefChannel; label: string }[] = [
@@ -73,7 +89,9 @@ export function NotificationMatrix() {
async function toggle(event: PrefEvent, channel: PrefChannel) { async function toggle(event: PrefEvent, channel: PrefChannel) {
if (!prefs) return; if (!prefs) return;
const next = !prefs[event][channel]; // Default-on when the key is absent: prefs are stored as opt-outs, and a
// BFF that predates this category simply omits it.
const next = !(prefs[event]?.[channel] ?? true);
setBusyCell(`${event}/${channel}`); setBusyCell(`${event}/${channel}`);
try { try {
// Turning a push cell ON only helps if this device can receive push — // Turning a push cell ON only helps if this device can receive push —
@@ -132,23 +150,34 @@ export function NotificationMatrix() {
{event.description} {event.description}
</p> </p>
</div> </div>
{CHANNELS.map((channel) => ( {CHANNELS.map((channel) => {
<div const deliverable =
key={channel.key} !event.channels || event.channels.includes(channel.key);
className="flex justify-center border-t border-border py-3" return (
> <div
{prefs ? ( key={channel.key}
<ToggleSwitch className="flex justify-center border-t border-border py-3"
checked={prefs[event.key][channel.key]} >
disabled={busyCell === `${event.key}/${channel.key}`} {!deliverable ? (
onToggle={() => toggle(event.key, channel.key)} <span
label={`${event.label} — ${channel.label}`} className="text-xs text-muted-foreground"
/> title={`${event.label} isn't delivered by ${channel.label.toLowerCase()}`}
) : ( >
<span className="text-xs text-muted-foreground">…</span> &mdash;
)} </span>
</div> ) : prefs ? (
))} <ToggleSwitch
checked={prefs[event.key]?.[channel.key] ?? true}
disabled={busyCell === `${event.key}/${channel.key}`}
onToggle={() => toggle(event.key, channel.key)}
label={`${event.label} — ${channel.label}`}
/>
) : (
<span className="text-xs text-muted-foreground">…</span>
)}
</div>
);
})}
</div> </div>
))} ))}
</div> </div>
@@ -5,13 +5,18 @@
* WebSocket connection to our Soketi (Pusher-compatible) server so the * WebSocket connection to our Soketi (Pusher-compatible) server so the
* bell badge + a toast update in realtime. * bell badge + a toast update in realtime.
* *
* Transport: subscribes to the PUBLIC channel `notifications.{userId}` and * Transport: subscribes to the PRIVATE channel `private-notifications.{userId}`
* binds the `notification` event the BFF triggers on each follow-request / * and binds the `notification` event the BFF triggers on each follow-request /
* follow-accept. The event is a lightweight nudge — we bump the unread * follow-accept. The event is a lightweight nudge — we bump the unread
* count locally and toast — while the authoritative list/count still come * count locally and toast — while the authoritative list/count still come
* from the REST endpoints. (Public channel for now; once Soketi App auth * from the REST endpoints.
* is configured, switch to a `private-` channel + a /broadcasting/auth *
* endpoint — only the channel name and a Pusher `authEndpoint` change.) * The channel is private so a subscription requires a signature the BFF only
* issues to that user; on the old public channel anyone who knew a user's id
* could listen in. Signing goes through `channelAuthorizer` (see
* lib/realtime.ts). The BFF still publishes to the old public channel too, so
* a service-worker-cached bundle running this file's previous version keeps
* working until it updates.
* *
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent * Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
* realtime silently disables and the bell falls back to its fetched count. * realtime silently disables and the bell falls back to its fetched count.
@@ -26,6 +31,7 @@ import {
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
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";
@@ -38,6 +44,22 @@ type NotificationsContextValue = {
refreshUnread: () => void; refreshUnread: () => void;
/** Zero the badge locally (after the page marks everything read). */ /** Zero the badge locally (after the page marks everything read). */
clearUnread: () => void; clearUnread: () => void;
/**
* Last chat message seen over realtime, or null. Chat deliberately does NOT
* flow through the notification tray — a new message highlights its row in
* the inbox instead — so it rides the same private channel under its own
* event name and is surfaced here rather than bumping `unread`.
*/
lastChatActivity: ChatActivity | null;
};
/** Payload of the BFF's `chat_activity` event. */
export type ChatActivity = {
conversation_id: string;
seq: number;
sender_id: string;
/** Client-side receipt time, so consumers can react to repeats. */
at: number;
}; };
const NotificationsContext = createContext<NotificationsContextValue | null>( const NotificationsContext = createContext<NotificationsContextValue | null>(
@@ -70,6 +92,8 @@ function messageFor(e: IncomingEvent): string {
return `${who} replied to your comment`; return `${who} replied to your comment`;
case "comment_mention": case "comment_mention":
return `${who} mentioned you in a comment`; return `${who} mentioned you in a comment`;
case "disciple_joined":
return `${who}, a direct disciple of Śrīla Prabhupāda, has joined`;
case "admin_message": case "admin_message":
return e.body || "A message from Prabhupāda Says"; return e.body || "A message from Prabhupāda Says";
default: default:
@@ -85,6 +109,9 @@ export function NotificationsProvider({
const { user } = useAuth(); const { user } = useAuth();
const [unread, setUnread] = useState(0); const [unread, setUnread] = useState(0);
const [lastEventAt, setLastEventAt] = useState(0); const [lastEventAt, setLastEventAt] = useState(0);
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
null,
);
const refreshUnread = useCallback(() => { const refreshUnread = useCallback(() => {
notifications notifications
@@ -143,7 +170,7 @@ export function NotificationsProvider({
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443"); const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS = const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false"; (process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
const channelName = `notifications.${userId}`; const channelName = `private-notifications.${userId}`;
let cancelled = false; let cancelled = false;
// Dynamic import keeps pusher-js out of SSR (it touches window) and the // Dynamic import keeps pusher-js out of SSR (it touches window) and the
@@ -160,8 +187,25 @@ export function NotificationsProvider({
disableStats: true, disableStats: true,
// Ignored when wsHost is set, but required by the type. // Ignored when wsHost is set, but required by the type.
cluster: "", cluster: "",
// Private channels are refused without a signature from the BFF.
channelAuthorization: { customHandler: channelAuthorizer },
}); });
const channel = pusher.subscribe(channelName); const channel = pusher.subscribe(channelName);
// Authorization can legitimately fail (expired session, realtime not
// configured). Realtime is a nudge, not the source of truth, so degrade
// quietly — the bell keeps working off its fetched count.
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.
channel.bind(
"chat_activity",
(data: Omit<ChatActivity, "at">) => {
if (!data?.conversation_id) return;
setLastChatActivity({ ...data, at: Date.now() });
},
);
channel.bind("notification", (data: IncomingEvent) => { channel.bind("notification", (data: IncomingEvent) => {
setUnread((u) => u + 1); setUnread((u) => u + 1);
setLastEventAt(Date.now()); setLastEventAt(Date.now());
@@ -192,7 +236,13 @@ export function NotificationsProvider({
return ( return (
<NotificationsContext.Provider <NotificationsContext.Provider
value={{ unread, lastEventAt, refreshUnread, clearUnread }} value={{
unread,
lastEventAt,
refreshUnread,
clearUnread,
lastChatActivity,
}}
> >
{children} {children}
</NotificationsContext.Provider> </NotificationsContext.Provider>
+89
View File
@@ -0,0 +1,89 @@
"use client";
/**
* DiscipleBadge — the small circular Śrīla Prabhupāda portrait that marks a
* profile as belonging to one of his own initiated disciples.
*
* Positioned by the caller (the profile header overlaps it on the avatar's
* lower-right, like a verification tick). Tapping it toggles a tooltip reading
* "Śrīla Prabhupāda's Direct Disciple" — click rather than hover, so it works
* identically on touch, where there is no hover state.
*
* The tooltip closes on a second tap, on Escape, or on any outside click.
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { cn } from "@/lib/utils";
const LABEL = "Śrīla Prabhupāda's Direct Disciple";
export function DiscipleBadge({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLSpanElement>(null);
// Dismiss on outside click / Escape. Bound only while open so we don't keep
// document-level listeners around for every profile view.
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<span ref={wrapRef} className={cn("relative inline-flex", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={LABEL}
aria-expanded={open}
// Native tooltip as a desktop-hover bonus; the popover below is the
// real (touch-friendly) affordance.
title={LABEL}
className={cn(
"size-8 sm:size-9 overflow-hidden rounded-full",
"bg-card ring-2 ring-card shadow-md",
"transition-transform hover:scale-105 active:scale-95",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Image
src="/images/prabhupada-disciple-badge.png"
alt=""
width={72}
height={72}
className="size-full object-cover"
/>
</button>
{open ? (
<span
role="tooltip"
className={cn(
"absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2",
"w-max max-w-[70vw] rounded-lg px-2.5 py-1.5",
"bg-foreground text-background text-xs font-medium text-center",
"shadow-lg animate-in fade-in-0 zoom-in-95",
)}
>
{/* Caret pointing back at the badge. */}
<span
aria-hidden
className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 bg-foreground"
/>
{LABEL}
</span>
) : null}
</span>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/**
* The Message button on someone else's profile.
*
* Shown only when the BFF says the viewer may actually start a conversation —
* `can_message` reflects the target's "who can message you" setting and any
* block. Offering a button that fails on tap is worse than not offering one, so
* when messaging is closed this explains why instead.
*
* A blocked profile shows nothing at all: the block itself is already conveyed
* by the rest of the action row, and spelling it out again would be noise.
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, MessageCircle } from "lucide-react";
import { toast } from "sonner";
import { useAuthGate } from "@/components/auth/auth-gate";
import { chat } from "@/lib/chat";
import type { RelationshipState } from "@/lib/social";
import { cn } from "@/lib/utils";
/** Why messaging is closed, in the target's own terms. */
const REASON: Record<string, string> = {
friends: "only accepts messages from friends",
account_age: "only accepts messages from established accounts",
same_gender: "limits who can message them",
};
export function MessageButton({
handle,
name,
relationship,
}: {
handle: string;
name: string;
relationship: RelationshipState | null;
}) {
const router = useRouter();
const { requireAuth } = useAuthGate();
const [busy, setBusy] = useState(false);
// Anonymous viewers get the button and hit the auth gate on tap — hiding it
// would make the feature invisible to exactly the people who need to sign up.
if (relationship && !relationship.can_message) {
const reason = REASON[relationship.message_restriction ?? ""];
if (!reason) return null; // blocked, or self — say nothing
return (
<p className="text-xs text-muted-foreground">
{name} {reason}.
</p>
);
}
const open = async () => {
if (!(await requireAuth())) return;
setBusy(true);
try {
const conv = await chat.openDM(handle);
router.push(`/chats/${conv.id}`);
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: "Could not open the conversation",
);
setBusy(false);
}
};
return (
<button
type="button"
onClick={open}
disabled={busy}
className={cn(
"inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 shrink-0",
"border border-border text-sm font-medium text-foreground hover:bg-muted",
"transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:opacity-50",
)}
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MessageCircle className="size-4" />
)}
Message
</button>
);
}
+5 -1
View File
@@ -58,6 +58,7 @@ import { countryName } from "@/lib/countries";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate"; import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { MessageButton } from "@/components/profile/message-button";
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@@ -206,8 +207,11 @@ export function ProfileActions({
return ( return (
<> <>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{primary} {primary}
{/* Offered only when the target's settings allow it — see
MessageButton. */}
<MessageButton handle={handle} name={name} relationship={rel} />
{/* Block / Report / Remove are signed-in-only (their BFF routes {/* Block / Report / Remove are signed-in-only (their BFF routes
require auth) and only appear when `authed`. "More info" is require auth) and only appear when `authed`. "More info" is
public, so the menu always renders with at least that item. */} public, so the menu always renders with at least that item. */}
+53
View File
@@ -31,6 +31,11 @@ import { compressImage } from "@/lib/compress-image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { MentionTextarea } from "@/components/social/mention-textarea";
import { cn } from "@/lib/utils";
/** Profile bio cap. Mirrors the BFF's service.MaxBioRunes. */
const BIO_MAX = 200;
import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
@@ -70,6 +75,7 @@ export function ProfileEditForm({
const [name, setName] = useState(user.name); const [name, setName] = useState(user.name);
const [handle, setHandle] = useState(user.handle); const [handle, setHandle] = useState(user.handle);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url); const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [bio, setBio] = useState<string>(user.bio ?? "");
const [country, setCountry] = useState<string>(user.country ?? ""); const [country, setCountry] = useState<string>(user.country ?? "");
// Devotional details. Each "" means "not specified". // Devotional details. Each "" means "not specified".
const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? ""); const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? "");
@@ -92,11 +98,15 @@ export function ProfileEditForm({
const origRounds = const origRounds =
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : ""; user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "";
const effInitiatedAt = initiated ? initiatedAt : ""; const effInitiatedAt = initiated ? initiatedAt : "";
// Spread, not .length — counts code points so emoji and Devanāgarī match
// the server's rune-based cap instead of over-counting UTF-16 units.
const bioCount = [...bio.trim()].length;
const dirty = const dirty =
name.trim() !== user.name || name.trim() !== user.name ||
handle.trim() !== user.handle || handle.trim() !== user.handle ||
avatarUrl !== user.avatar_url || avatarUrl !== user.avatar_url ||
bio.trim() !== (user.bio ?? "") ||
country !== (user.country ?? "") || country !== (user.country ?? "") ||
sampradaya !== (user.sampradaya ?? "") || sampradaya !== (user.sampradaya ?? "") ||
gender !== (user.gender ?? "") || gender !== (user.gender ?? "") ||
@@ -108,6 +118,13 @@ export function ProfileEditForm({
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
// Counted in code points to match the BFF's rune-based cap, so a bio in
// Devanāgarī is measured the same way as one in Latin script.
if ([...bio.trim()].length > BIO_MAX) {
setError(`Bio must be ${BIO_MAX} characters or fewer.`);
return;
}
// Client-side guard for the round count (the BFF re-checks 1–1008). // Client-side guard for the round count (the BFF re-checks 1–1008).
if (rounds !== "") { if (rounds !== "") {
const n = Number(rounds); const n = Number(rounds);
@@ -126,6 +143,8 @@ export function ProfileEditForm({
if (name.trim() !== user.name) patch.name = name.trim(); if (name.trim() !== user.name) patch.name = name.trim();
if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? ""; if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? "";
if (handle.trim() !== user.handle) patch.handle = handle.trim(); if (handle.trim() !== user.handle) patch.handle = handle.trim();
// "" clears the bio.
if (bio.trim() !== (user.bio ?? "")) patch.bio = bio.trim();
if (country !== (user.country ?? "")) patch.country = country; if (country !== (user.country ?? "")) patch.country = country;
// "" clears the lineage; a code sets it. // "" clears the lineage; a code sets it.
if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya; if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya;
@@ -221,6 +240,40 @@ export function ProfileEditForm({
/> />
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="profile-bio">Bio</Label>
{/* MentionTextarea (shared with the post composer and comment box)
gives us the debounced "@" suggestion list for free — typing an
@token opens a people picker prioritised following → followers. */}
<MentionTextarea
id="profile-bio"
value={bio}
onChange={setBio}
rows={3}
maxLength={BIO_MAX}
placeholder="A little about you… mention devotees with @handle"
// Mirrors the <Input> primitive's chrome so the bio sits flush
// with the other fields (auto height instead of the fixed h-10).
className="w-full min-w-0 resize-y rounded-lg border border-input bg-card px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-card"
/>
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground">
Type <span className="font-medium text-foreground">@</span> to
mention another devotee — mentions become links on your profile.
</p>
<span
className={cn(
"shrink-0 text-xs tabular-nums",
bioCount > BIO_MAX
? "font-medium text-destructive"
: "text-muted-foreground",
)}
>
{bioCount}/{BIO_MAX}
</span>
</div>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="profile-handle">Handle</Label> <Label htmlFor="profile-handle">Handle</Label>
{/* "@" sits inside the field as a fixed, non-editable prefix — {/* "@" sits inside the field as a fixed, non-editable prefix —
+40 -9
View File
@@ -34,6 +34,8 @@ import { Settings, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { RelationshipResult, RelationshipState } from "@/lib/social"; import type { RelationshipResult, RelationshipState } from "@/lib/social";
import { ProfileActions } from "@/components/profile/profile-actions"; import { ProfileActions } from "@/components/profile/profile-actions";
import { DiscipleBadge } from "@/components/profile/disciple-badge";
import { MentionText } from "@/components/social/mention-text";
import { UserAvatar } from "@/components/ui/user-avatar"; import { UserAvatar } from "@/components/ui/user-avatar";
import { import {
FollowListDrawer, FollowListDrawer,
@@ -53,6 +55,8 @@ export function ProfileHeader({
enableActions = false, enableActions = false,
country, country,
interactiveCounts = true, interactiveCounts = true,
isDirectDisciple = false,
bio,
}: { }: {
avatarURL: string | null; avatarURL: string | null;
name: string; name: string;
@@ -81,6 +85,14 @@ export function ProfileHeader({
* click target) and the follow-list drawer isn't mounted — used on a * click target) and the follow-list drawer isn't mounted — used on a
* blocked profile, where the lists endpoint is gated server-side. */ * blocked profile, where the lists endpoint is gated server-side. */
interactiveCounts?: boolean; interactiveCounts?: boolean;
/** Set when an admin has linked this account to the disciples directory —
* one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait
* on the avatar; tapping it explains the mark. */
isDirectDisciple?: boolean;
/** The profile's bio. Plain text — `@handle` mentions are linkified on
* render. Sits between the stat line and the action buttons, centred like
* the rest of the header. Omitted entirely when empty. */
bio?: string | null;
}) { }) {
// Owner affordances ride on `editHref`; the follow-action row rides on // Owner affordances ride on `editHref`; the follow-action row rides on
// `enableActions`. Mutually exclusive by construction. // `enableActions`. Mutually exclusive by construction.
@@ -111,15 +123,22 @@ export function ProfileHeader({
return ( return (
<> <>
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8"> <header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
<UserAvatar {/* Avatar, with Śrīla Prabhupāda's portrait tucked into the lower-right
src={avatarURL} for his direct disciples (verification-tick placement). */}
name={name} <div className="relative">
className="size-24 sm:size-28" <UserAvatar
textClassName="text-3xl" src={avatarURL}
interactive name={name}
onClick={avatarURL ? () => setZoomOpen(true) : undefined} className="size-24 sm:size-28"
ariaLabel={`View ${name}'s profile picture`} textClassName="text-3xl"
/> interactive
onClick={avatarURL ? () => setZoomOpen(true) : undefined}
ariaLabel={`View ${name}'s profile picture`}
/>
{isDirectDisciple ? (
<DiscipleBadge className="absolute -bottom-0.5 -right-0.5" />
) : null}
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight"> <h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight">
@@ -158,6 +177,18 @@ export function ProfileHeader({
</div> </div>
</div> </div>
{/* Bio — between the stats and the action buttons, centred to match the
rest of the header. `self-stretch` overrides the header's
`items-center` so the text box spans the full column and each
wrapped line centres against the same axis as the name and stats
(without it the box would shrink to its content and centre oddly). */}
{bio?.trim() ? (
<MentionText
text={bio.trim()}
className="self-stretch text-center text-sm sm:text-[15px] leading-relaxed text-foreground"
/>
) : null}
{showOwnerControls ? ( {showOwnerControls ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
+2 -21
View File
@@ -18,29 +18,10 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { comments as commentsApi, SocialError, type Comment } from "@/lib/social"; import { comments as commentsApi, SocialError, type Comment } from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar"; import { UserAvatar } from "@/components/ui/user-avatar";
import { renderMentions } from "@/components/social/mention-text";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
import { CommentMenu } from "./comment-menu"; import { CommentMenu } from "./comment-menu";
// Split a comment body into text + clickable @handle links. The capturing
// group keeps the delimiters so they can be turned into profile links.
const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
function renderBody(text: string) {
return text.split(MENTION_TOKEN).map((part, i) => {
if (part.length > 1 && part[0] === "@") {
return (
<Link
key={i}
href={`/u/${part.slice(1)}`}
className="font-medium text-primary hover:underline"
>
{part}
</Link>
);
}
return part;
});
}
export function CommentRow({ export function CommentRow({
comment, comment,
viewerId, viewerId,
@@ -172,7 +153,7 @@ export function CommentRow({
@{comment.reply_to_handle}{" "} @{comment.reply_to_handle}{" "}
</span> </span>
) : null} ) : null}
{renderBody(comment.body)} {renderMentions(comment.body)}
</p> </p>
{/* Inline action row */} {/* Inline action row */}
+56
View File
@@ -0,0 +1,56 @@
"use client";
/**
* MentionText — renders user-authored plain text with `@handle` tokens turned
* into profile links.
*
* The single place that decides what a mention looks like. Shared by comment
* bodies and profile bios so the two can't drift apart, and so the
* "@handle" grammar lives next to the regex the composer
* (`MentionTextarea`) uses to detect it.
*
* Input is always PLAIN TEXT, never HTML — we split on the token and emit
* React nodes, so nothing user-supplied is ever interpreted as markup.
* `whitespace-pre-line` on the wrapper preserves the author's line breaks
* (bios are multi-line) without needing <br> injection.
*/
import Link from "next/link";
import { cn } from "@/lib/utils";
/** Capturing group keeps the delimiters, so split() yields text and tokens
* alternately. Handle charset matches MentionTextarea's MENTION_RE. */
const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
/** Split text into plain runs + clickable @handle links. Exported for callers
* that need the nodes without the wrapper element. */
export function renderMentions(text: string) {
return text.split(MENTION_TOKEN).map((part, i) => {
if (part.length > 1 && part[0] === "@") {
return (
<Link
key={i}
href={`/u/${part.slice(1)}`}
className="font-medium text-primary hover:underline"
>
{part}
</Link>
);
}
return part;
});
}
export function MentionText({
text,
className,
}: {
text: string;
className?: string;
}) {
return (
<span className={cn("whitespace-pre-line", className)}>
{renderMentions(text)}
</span>
);
}
+59 -9
View File
@@ -33,22 +33,30 @@ const SUGGEST_LIMIT = 8;
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/; const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
type Mention = { start: number; query: string }; type Mention = { start: number; query: string };
type PanelPos = { left: number; bottom: number; width: number }; /** Exactly one of `top`/`bottom` is set — see the flip logic in detect(). */
type PanelPos = { left: number; width: number; top?: number; bottom?: number };
/** Keep in sync with the panel's `max-h-56` (14rem). Used to decide whether
* the suggestion list fits above the field or has to flip below it. */
const PANEL_MAX_H = 224;
export const MentionTextarea = forwardRef< export const MentionTextarea = forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
{ {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
/** Called on Enter (no Shift) when the suggestion list is closed. */ /** Called on Enter (no Shift) when the suggestion list is closed. Omit for
* multi-line fields — without it, Enter inserts a newline as usual. */
onSubmit?: () => void; onSubmit?: () => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
maxLength?: number; maxLength?: number;
rows?: number; rows?: number;
/** Forwarded to the <textarea> so a <Label htmlFor> can address it. */
id?: string;
} }
>(function MentionTextarea( >(function MentionTextarea(
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1 }, { value, onChange, onSubmit, placeholder, className, maxLength, rows = 1, id },
ref, ref,
) { ) {
const innerRef = useRef<HTMLTextAreaElement>(null); const innerRef = useRef<HTMLTextAreaElement>(null);
@@ -61,6 +69,7 @@ export const MentionTextarea = forwardRef<
const [pos, setPos] = useState<PanelPos | null>(null); const [pos, setPos] = useState<PanelPos | null>(null);
const reqRef = useRef(0); const reqRef = useRef(0);
const blurTimer = useRef<number | null>(null); const blurTimer = useRef<number | null>(null);
const listRef = useRef<HTMLDivElement>(null);
// Detect the active @token from the text up to the caret. // Detect the active @token from the text up to the caret.
function detect(el: HTMLTextAreaElement) { function detect(el: HTMLTextAreaElement) {
@@ -70,14 +79,36 @@ export const MentionTextarea = forwardRef<
setMention(null); setMention(null);
return; return;
} }
setMention({ start: caret - m[2].length - 1, query: m[2] }); const next = { start: caret - m[2].length - 1, query: m[2] };
setActive(0);
// Reposition unconditionally — the field grows as text wraps, so the panel
// has to stay pinned to it even when the token itself hasn't changed.
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
// Prefer opening ABOVE the field: that's what the comment composer needs
// (it sits at the bottom of a drawer, often above the on-screen keyboard).
// But flip BELOW when the field is too near the top of the viewport for the
// panel to fit — otherwise the list renders off-screen and the feature
// looks broken (e.g. the bio field high up on the profile editor).
const fitsAbove = r.top >= PANEL_MAX_H + 8;
setPos({ setPos({
left: r.left, left: r.left,
bottom: window.innerHeight - r.top + 6,
width: r.width, width: r.width,
...(fitsAbove
? { bottom: window.innerHeight - r.top + 6 }
: { top: r.bottom + 6 }),
}); });
// Bail out if the token is byte-for-byte what we already have. detect()
// also runs on keyup and click, so re-seeding an identical mention would:
// • reset `active` to 0 — which cancelled the ↑/↓ highlight the instant
// the key came back up, making arrow navigation look dead; and
// • hand the fetch effect a fresh object identity, re-requesting
// suggestions on every single keystroke.
if (mention && mention.start === next.start && mention.query === next.query) {
return;
}
setMention(next);
setActive(0);
} }
// Fetch suggestions (debounced) whenever the active token changes. // Fetch suggestions (debounced) whenever the active token changes.
@@ -120,6 +151,17 @@ export const MentionTextarea = forwardRef<
const listOpen = !!mention && (loading || items.length > 0); const listOpen = !!mention && (loading || items.length > 0);
// Keep the highlighted row visible: the panel caps at max-h-56, which holds
// ~5 of the 8 suggestions, so arrowing past the fifth would otherwise move
// the highlight out of sight. `block: "nearest"` scrolls the panel the
// minimum amount and never scrolls the page behind it.
useEffect(() => {
if (!listOpen) return;
listRef.current
?.querySelector<HTMLElement>(`[data-idx="${active}"]`)
?.scrollIntoView({ block: "nearest" });
}, [active, listOpen]);
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (listOpen && items.length > 0) { if (listOpen && items.length > 0) {
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
@@ -143,9 +185,12 @@ export const MentionTextarea = forwardRef<
return; return;
} }
} }
if (e.key === "Enter" && !e.shiftKey) { // Only hijack Enter when the caller actually wants submit-on-Enter.
// Without the `onSubmit` guard we'd swallow the keystroke for multi-line
// callers (e.g. the profile bio), leaving them unable to type a newline.
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
e.preventDefault(); e.preventDefault();
onSubmit?.(); onSubmit();
} }
} }
@@ -153,6 +198,7 @@ export const MentionTextarea = forwardRef<
<div className="relative flex-1"> <div className="relative flex-1">
<textarea <textarea
ref={innerRef} ref={innerRef}
id={id}
value={value} value={value}
rows={rows} rows={rows}
maxLength={maxLength} maxLength={maxLength}
@@ -175,13 +221,16 @@ export const MentionTextarea = forwardRef<
{listOpen && pos && typeof document !== "undefined" {listOpen && pos && typeof document !== "undefined"
? createPortal( ? createPortal(
<div <div
ref={listRef}
role="listbox" role="listbox"
aria-label="Mention suggestions" aria-label="Mention suggestions"
style={{ style={{
position: "fixed", position: "fixed",
left: pos.left, left: pos.left,
bottom: pos.bottom,
width: pos.width, width: pos.width,
...(pos.top !== undefined
? { top: pos.top }
: { bottom: pos.bottom }),
}} }}
// pointer-events-auto: the comments drawer is a modal vaul sheet // pointer-events-auto: the comments drawer is a modal vaul sheet
// (body gets pointer-events:none); without this the list would be // (body gets pointer-events:none); without this the list would be
@@ -194,6 +243,7 @@ export const MentionTextarea = forwardRef<
key={u.id ?? u.handle} key={u.id ?? u.handle}
type="button" type="button"
role="option" role="option"
data-idx={i}
aria-selected={i === active} aria-selected={i === active}
// Keep textarea focus + cancel the pending blur-close so the // Keep textarea focus + cancel the pending blur-close so the
// click registers before the list would otherwise close. // click registers before the list would otherwise close.
+29 -4
View File
@@ -20,6 +20,7 @@
* posts (or imported data) still scroll to + highlight the text on click. * posts (or imported data) still scroll to + highlight the text on click.
*/ */
import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ExternalLink, Repeat2 } from "lucide-react"; import { ExternalLink, Repeat2 } from "lucide-react";
@@ -30,6 +31,7 @@ import { UserAvatar } from "@/components/ui/user-avatar";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
import { PostActionsFooter } from "./post-actions-footer"; import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu"; import { PostCardMenu } from "./post-card-menu";
import { PostImageLightbox } from "./post-image-lightbox";
export function PostCard({ export function PostCard({
post, post,
@@ -38,6 +40,11 @@ export function PostCard({
post: Post; post: Post;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
}) { }) {
// Which attached photo the full-screen viewer is showing (null = closed).
// Owned here rather than inside the lightbox so the thumbnails can open it
// at their own index.
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const sourceHref = const sourceHref =
hasHighlight(post.source_url) || !post.caption hasHighlight(post.source_url) || !post.caption
? post.source_url ? post.source_url
@@ -125,10 +132,20 @@ export function PostCard({
: "grid-cols-3" : "grid-cols-3"
}`} }`}
> >
{post.images.map((img) => ( {post.images.map((img, i) => (
<div // Each thumbnail opens the full-screen viewer at its own index.
// A <button> rather than a click-handled <div> so it's keyboard
// reachable and announced — the grid is the only way in.
<button
key={img.id} key={img.id}
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10" type="button"
onClick={() => setLightboxIndex(i)}
aria-label={
post.images.length > 1
? `View photo ${i + 1} of ${post.images.length}`
: "View photo"
}
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 cursor-pointer transition hover:ring-foreground/30 outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<Image <Image
src={img.url} src={img.url}
@@ -138,11 +155,19 @@ export function PostCard({
className="object-cover" className="object-cover"
unoptimized unoptimized
/> />
</div> </button>
))} ))}
</div> </div>
) : null} ) : null}
<PostImageLightbox
images={post.images ?? []}
index={lightboxIndex}
caption={post.caption}
onIndexChange={setLightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground"> <div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<Link <Link
href={sourceHref} href={sourceHref}
@@ -0,0 +1,238 @@
"use client";
/**
* PostImageLightbox — full-screen, swipeable viewer for a post's photos.
*
* Opened by tapping any image on a PostCard or PostScrollCard. Controlled by
* the caller: `index` is the photo on screen (null = closed), so the card owns
* the state and this stays presentation-only.
*
* Navigation is a native CSS scroll-snap strip — the same primitive the feed
* carousel uses — so swiping works on touch for free, and the ← / → keys and
* chevrons simply scroll that strip. The scroller is the single source of
* truth: buttons/keys never set `index` directly, they scroll, and the scroll
* handler reports the settled index back up. That one-way flow is what keeps a
* programmatic scroll from fighting the user's own gesture.
*
* Dismiss: tap the photo or the surrounding ring, the ✕, or press Escape. Taps
* are distinguished from swipes by useTapGuard, so navigating never closes.
*
* Rendered through a portal onto <body> so it escapes the feed's virtualised,
* transformed rows — an overlay nested inside one would be positioned against
* that transform and clipped by its stacking context, not the viewport.
*/
import { useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { PostImage } from "@/lib/social";
import { useTapGuard } from "./use-tap-guard";
export function PostImageLightbox({
images,
index,
caption,
onIndexChange,
onClose,
}: {
images: PostImage[];
/** Photo currently shown; null closes the viewer. */
index: number | null;
/** The post's caption, shown in a gradient scrim along the bottom.
* Clamped to three lines with an ellipsis so a long passage can't cover
* the photo. Omit (or pass empty) to hide the scrim entirely. */
caption?: string | null;
onIndexChange: (i: number) => void;
onClose: () => void;
}) {
const open = index !== null;
const count = images.length;
const scrollerRef = useRef<HTMLDivElement>(null);
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
/** Scroll the strip to a slide. Clamped, not wrapped: the buttons and keys
* should be able to reach exactly what a swipe can, and you can't swipe
* past the end. */
const scrollToIndex = useCallback(
(i: number, behavior: ScrollBehavior) => {
const el = scrollerRef.current;
if (!el) return;
const target = Math.max(0, Math.min(i, count - 1));
el.scrollTo({ left: target * el.offsetWidth, behavior });
},
[count],
);
const goPrev = useCallback(() => {
if (index === null) return;
scrollToIndex(index - 1, "smooth");
}, [index, scrollToIndex]);
const goNext = useCallback(() => {
if (index === null) return;
scrollToIndex(index + 1, "smooth");
}, [index, scrollToIndex]);
// Jump to the tapped photo when the viewer opens. Deliberately keyed on
// `open` only — re-running on every `index` change would yank the strip back
// mid-swipe, since scrolling is what updates `index` in the first place.
useEffect(() => {
if (!open) return;
scrollToIndex(index ?? 0, "auto");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft") goPrev();
else if (e.key === "ArrowRight") goNext();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose, goPrev, goNext]);
// Lock body scroll while open so the feed underneath doesn't drift.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
function onScroll(e: React.UIEvent<HTMLDivElement>) {
const el = e.currentTarget;
const w = el.offsetWidth;
if (w === 0) return;
const i = Math.round(el.scrollLeft / w);
if (i !== index && i >= 0 && i < count) onIndexChange(i);
}
if (!open || typeof document === "undefined") return null;
if (!images[index]) return null;
return (
createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={count > 1 ? `Photo ${index + 1} of ${count}` : "Photo"}
onClick={onClose}
className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0"
>
{/* The strip sits inset from the edges, leaving a backdrop ring that
also closes. `absolute inset-*` rather than padding because padding
does NOT inset an absolutely-positioned `fill` child. */}
<div
ref={scrollerRef}
onScroll={onScroll}
onPointerDown={onPointerDown}
onClick={(e) => {
// Swiping between photos must never dismiss the viewer; only a
// genuine tap falls through to the backdrop's onClose.
if (!wasTap(e)) e.stopPropagation();
}}
className="absolute inset-4 sm:inset-12 flex overflow-x-auto snap-x snap-mandatory overscroll-contain [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
>
{images.map((img, i) => (
<div
key={img.id}
className="relative h-full w-full shrink-0 snap-center"
>
<Image
src={img.url}
alt=""
fill
sizes="100vw"
className="object-contain"
unoptimized
// next/image defaults to loading="lazy". That's right for the
// feed, but wrong here: this only mounts once already open, so
// every slide is on (or one swipe from) screen. Left lazy, the
// browser waits for the intersection heuristic before it even
// starts — which is the pause you'd see on open despite the
// bytes already sitting in cache from the card.
loading="eager"
// The slide being viewed outranks its neighbours in the queue.
fetchPriority={i === index ? "high" : "auto"}
/>
</div>
))}
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="fixed top-4 right-4 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<X className="size-5" />
</button>
{count > 1 ? (
<>
<button
type="button"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
aria-label="Previous photo"
className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
disabled={index === count - 1}
onClick={(e) => {
e.stopPropagation();
goNext();
}}
aria-label="Next photo"
className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
>
<ChevronRight className="size-5" />
</button>
{/* Counter sits top-left so it never collides with the caption
scrim along the bottom. */}
<span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums">
{index + 1} / {count}
</span>
{/* Dot indicators, mirroring the in-feed carousel. */}
<div className="pointer-events-none fixed inset-x-0 bottom-1.5 flex items-center justify-center gap-1.5">
{images.map((img, i) => (
<span
key={img.id}
aria-hidden
className={`size-1.5 rounded-full transition-all ${
i === index ? "bg-white" : "bg-white/40"
}`}
/>
))}
</div>
</>
) : null}
{/* Caption scrim. `pointer-events-none` so it never intercepts a tap
meant for the backdrop, and the gradient fades upward so the
photo's lower edge stays readable rather than cut by a hard band. */}
{caption?.trim() ? (
<div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-8">
<p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3">
&ldquo;{caption.trim()}&rdquo;
</p>
</div>
) : null}
</div>,
document.body,
)
);
}
+44 -8
View File
@@ -40,6 +40,8 @@ import { useAuth } from "@/components/auth/auth-provider";
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared"; import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer"; import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu"; import { PostCardMenu } from "./post-card-menu";
import { PostImageLightbox } from "./post-image-lightbox";
import { useTapGuard } from "./use-tap-guard";
import { FeedFollowButton } from "./feed-follow-button"; import { FeedFollowButton } from "./feed-follow-button";
import { UpdateBackgroundModal } from "../update-background-modal"; import { UpdateBackgroundModal } from "../update-background-modal";
import { MentionsDrawer } from "../mentions-drawer"; import { MentionsDrawer } from "../mentions-drawer";
@@ -68,6 +70,8 @@ export function PostScrollCard({
const { user } = useAuth(); const { user } = useAuth();
const isAuthor = !!user && user.id === post.user_id; const isAuthor = !!user && user.id === post.user_id;
const [bgModal, setBgModal] = useState(false); const [bgModal, setBgModal] = useState(false);
// Which attached photo the full-screen viewer is showing (null = closed).
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [mentionsOpen, setMentionsOpen] = useState(false); const [mentionsOpen, setMentionsOpen] = useState(false);
// Live mention count — seeded from the hydrated post, adjusted when the // Live mention count — seeded from the hydrated post, adjusted when the
@@ -176,7 +180,7 @@ export function PostScrollCard({
{/* Media — image carousel or gradient-with-quote */} {/* Media — image carousel or gradient-with-quote */}
<div className="relative"> <div className="relative">
{hasImages ? ( {hasImages ? (
<ImageCarousel images={images} /> <ImageCarousel images={images} onOpen={setLightboxIndex} />
) : ( ) : (
<QuotePanel post={post} /> <QuotePanel post={post} />
)} )}
@@ -225,14 +229,33 @@ export function PostScrollCard({
onClose={() => setMentionsOpen(false)} onClose={() => setMentionsOpen(false)}
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))} onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
/> />
<PostImageLightbox
images={images}
index={lightboxIndex}
caption={post.caption}
onIndexChange={setLightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
</article> </article>
); );
} }
// ─── Pieces ────────────────────────────────────────────────────────────── // ─── Pieces ──────────────────────────────────────────────────────────────
function ImageCarousel({ images }: { images: PostImage[] }) { function ImageCarousel({
images,
onOpen,
}: {
images: PostImage[];
/** Tapping a slide opens the full-screen viewer at that index. */
onOpen: (index: number) => void;
}) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const scrollerRef = useRef<HTMLDivElement>(null);
// Swiping the carousel and tapping a photo have to stay independent — the
// strip scrolls natively, and browsers still fire a click after some swipes.
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
function onScroll(e: React.UIEvent<HTMLDivElement>) { function onScroll(e: React.UIEvent<HTMLDivElement>) {
const el = e.currentTarget; const el = e.currentTarget;
@@ -242,8 +265,14 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
} }
if (images.length === 1) { if (images.length === 1) {
// Single image: no scroller, so every click is unambiguously a tap.
return ( return (
<div className="relative aspect-square bg-muted"> <button
type="button"
onClick={() => onOpen(0)}
aria-label="View photo"
className="relative block w-full aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<Image <Image
src={images[0].url} src={images[0].url}
alt="" alt=""
@@ -252,13 +281,14 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
className="object-cover" className="object-cover"
unoptimized unoptimized
/> />
</div> </button>
); );
} }
return ( return (
<div className="relative"> <div className="relative">
<div <div
ref={scrollerRef}
onScroll={onScroll} onScroll={onScroll}
className={cn( className={cn(
"flex overflow-x-auto snap-x snap-mandatory", "flex overflow-x-auto snap-x snap-mandatory",
@@ -266,11 +296,17 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
)} )}
> >
{images.map((img, i) => ( {images.map((img, i) => (
<div <button
key={img.id} key={img.id}
className="relative w-full shrink-0 snap-start aspect-square bg-muted" type="button"
onPointerDown={onPointerDown}
onClick={(e) => {
if (!wasTap(e)) return; // that was a swipe — let it scroll
onOpen(i);
}}
aria-roledescription="slide" aria-roledescription="slide"
aria-label={`${i + 1} of ${images.length}`} aria-label={`View photo ${i + 1} of ${images.length}`}
className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
> >
<Image <Image
src={img.url} src={img.url}
@@ -280,7 +316,7 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
className="object-cover" className="object-cover"
unoptimized unoptimized
/> />
</div> </button>
))} ))}
</div> </div>
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none"> <div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
+51
View File
@@ -0,0 +1,51 @@
"use client";
/**
* useTapGuard — tells a genuine tap apart from a swipe on a natively-scrolling
* strip (CSS scroll-snap carousels).
*
* Browsers still fire a `click` after some swipe gestures, so a plain onClick
* on a slide would trigger mid-swipe. Snapshot the pointer position AND the
* scroller's offset on pointerdown, then check both when the click arrives:
*
* • pointer travel — catches a drag that didn't scroll (e.g. hit the end)
* • scroll movement — catches an inertial swipe, where the pointer barely
* moves before `click` fires but the strip is already gliding
*
* Either signal alone misses cases, hence both. No staleness risk: a click is
* always immediately preceded by a pointerdown on the same element.
*/
import { useRef, type RefObject } from "react";
/** Pointer travel (px) still considered a tap rather than a drag. */
const MOVE_TOLERANCE = 8;
/** Scroll movement (px) still considered "didn't scroll". */
const SCROLL_TOLERANCE = 4;
type PointerLike = { clientX: number; clientY: number };
export function useTapGuard(scrollerRef: RefObject<HTMLElement | null>) {
const start = useRef({ x: 0, y: 0, scrollLeft: 0 });
function onPointerDown(e: PointerLike) {
start.current = {
x: e.clientX,
y: e.clientY,
scrollLeft: scrollerRef.current?.scrollLeft ?? 0,
};
}
function wasTap(e: PointerLike) {
const moved =
Math.abs(e.clientX - start.current.x) > MOVE_TOLERANCE ||
Math.abs(e.clientY - start.current.y) > MOVE_TOLERANCE;
const scrolled =
Math.abs(
(scrollerRef.current?.scrollLeft ?? 0) - start.current.scrollLeft,
) > SCROLL_TOLERANCE;
return !moved && !scrolled;
}
return { onPointerDown, wasTap };
}
+8
View File
@@ -33,6 +33,12 @@ export type User = {
* the "Change password" modal and the "Set a password" flow. * the "Change password" modal and the "Set a password" flow.
*/ */
has_password: boolean; has_password: boolean;
/** True when an admin has linked this account to the disciples directory —
* one of Śrīla Prabhupāda's own initiated disciples. Admin-set only. */
is_direct_disciple?: boolean;
/** Short self-introduction (≤200 chars), plain text with `@handle`
* mentions linkified at render time. null when unset. */
bio: string | null;
/** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null /** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null
* when unknown. Captured best-effort at signup; user-editable. */ * when unknown. Captured best-effort at signup; user-editable. */
country: string | null; country: string | null;
@@ -222,6 +228,8 @@ export const auth = {
name?: string; name?: string;
avatar_url?: string | null; avatar_url?: string | null;
handle?: string; handle?: string;
/** Plain text, max 200 chars; "" clears it. */
bio?: string;
country?: string | null; country?: string | null;
sampradaya?: string; sampradaya?: string;
gender?: string | null; gender?: string | null;
+101
View File
@@ -0,0 +1,101 @@
/**
* Rendering helpers for chat messages — the client-side counterpart to the
* BFF's entity parser.
*
* The server stores clean text plus spans; nothing here interprets markup, so
* a message can never inject markup into someone else's thread.
*
* Entity offsets are UTF-16 code units, which is exactly what String.slice
* indexes by — so the arithmetic below needs no conversion. The server counts
* the same way for this reason; it matters constantly in this app, where
* Devanāgarī is multi-byte and emoji are surrogate pairs.
*/
import type { MessageEntity, Receipts } from "@/lib/chat";
export type EntityNode =
| { kind: "text"; text: string }
| { kind: "entity"; entity: MessageEntity; children: EntityNode[] };
/**
* Turns a body plus its spans into a nestable tree, so `*bold with `code`*`
* renders as one bold run containing a code run.
*
* Spans that only partially overlap another are dropped rather than clipped:
* the server never emits them, and rendering half a span is a worse failure
* than rendering none of it.
*/
export function buildEntityTree(
body: string,
entities: MessageEntity[],
): EntityNode[] {
const sorted = [...(entities ?? [])].sort(
(a, b) => a.offset - b.offset || b.length - a.length,
);
return build(body, 0, body.length, sorted);
}
function build(
body: string,
start: number,
end: number,
entities: MessageEntity[],
): EntityNode[] {
const nodes: EntityNode[] = [];
let cursor = start;
let i = 0;
while (i < entities.length) {
const e = entities[i];
const eStart = e.offset;
const eEnd = e.offset + e.length;
if (eStart >= end) break;
// Partially overlapping or already-consumed spans: skip.
if (eEnd > end || eStart < cursor || e.length <= 0) {
i += 1;
continue;
}
if (eStart > cursor) {
nodes.push({ kind: "text", text: body.slice(cursor, eStart) });
}
// Everything fully inside this span belongs to it.
const children: MessageEntity[] = [];
let j = i + 1;
while (j < entities.length && entities[j].offset + entities[j].length <= eEnd) {
children.push(entities[j]);
j += 1;
}
nodes.push({
kind: "entity",
entity: e,
children: build(body, eStart, eEnd, children),
});
cursor = eEnd;
i = j;
}
if (cursor < end) {
nodes.push({ kind: "text", text: body.slice(cursor, end) });
}
return nodes;
}
/** Tick state for one of your own messages. */
export type TickState = "sent" | "delivered" | "read";
/**
* Read wins over delivered, delivered over sent.
*
* The cursors are the MINIMUM across every other participant, so in a group a
* message only turns blue once everyone has read it.
*/
export function tickState(seq: number, receipts: Receipts | null): TickState {
if (!receipts) return "sent";
if (receipts.read_seq >= seq) return "read";
if (receipts.delivered_seq >= seq) return "delivered";
return "sent";
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Groups a thread's messages the way Instagram does: consecutive messages from
* one sender, sent close together, render as a stack with a single avatar and
* one timestamp, and a date separator marks each new day.
*
* Pure and message-shaped so it can be reasoned about (and checked) without a
* DOM.
*/
import type { ChatMessage } from "@/lib/chat";
/** How long a pause before the same sender starts a new stack. */
export const GROUP_GAP_MS = 5 * 60 * 1000;
export type ChatRow =
| { kind: "date"; key: string; at: string }
| {
kind: "group";
key: string;
senderId: string | null;
messages: ChatMessage[];
};
function dayKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
/**
* `messages` must be ascending by seq (oldest first) — the order the thread is
* rendered in.
*/
export function groupMessages(
messages: ChatMessage[],
gapMs: number = GROUP_GAP_MS,
): ChatRow[] {
const rows: ChatRow[] = [];
let current: Extract<ChatRow, { kind: "group" }> | null = null;
let currentDay = "";
for (const m of messages) {
const day = dayKey(m.created_at);
if (day !== currentDay) {
rows.push({ kind: "date", key: `date-${day}`, at: m.created_at });
currentDay = day;
current = null; // a new day always starts a new stack
}
const last = current?.messages[current.messages.length - 1];
const gap = last
? new Date(m.created_at).getTime() - new Date(last.created_at).getTime()
: Infinity;
// System notices stand alone — they belong to no one and shouldn't be
// absorbed into a neighbouring stack.
const standalone = m.kind === "system";
const continues =
current !== null &&
!standalone &&
current.senderId === (m.sender_id ?? null) &&
current.messages[0]?.kind !== "system" &&
gap <= gapMs;
if (continues && current) {
current.messages.push(m);
continue;
}
current = {
kind: "group",
key: `group-${m.id}`,
senderId: m.sender_id ?? null,
messages: [m],
};
rows.push(current);
if (standalone) current = null;
}
return rows;
}
+562
View File
@@ -0,0 +1,562 @@
/**
* Browser-side BFF client for chat — the inbox, threads and messages.
*
* Mirrors lib/social.ts: `credentials: "include"` so the auth cookie travels,
* and the {success, data} envelope is unwrapped here so callers see plain data.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class ChatError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ChatError";
}
}
type Envelope<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
};
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
if (!res.ok || body.success === false) {
throw new ChatError(res.status, body.error?.message || res.statusText);
}
return (body.data ?? (undefined as unknown as T)) as T;
}
// ─── Types ───────────────────────────────────────────────────────────────
/** Which inbox tab a thread belongs to. `ai` is Kautilya. */
export type ConversationKind = "dm" | "group" | "ai";
/** Roles inside a group, most privileged first. */
export type GroupRole = "owner" | "admin" | "member";
/** Who may post in a group. */
export type SendPolicy = "everyone" | "admins";
export type ChatPeer = {
id: string;
handle: string;
name: string;
avatar_url?: string | null;
/** Present on participant lists; absent on a DM's `peer`. */
role?: GroupRole;
joined_at?: string | null;
};
export type MessagePreview = {
seq: number;
sender_id?: string | null;
sender_name: string;
kind: string;
body: string;
deleted_at?: string | null;
};
export type InboxItem = {
id: string;
kind: ConversationKind;
title?: string | null;
avatar_key?: string | null;
send_policy: SendPolicy;
last_seq: number;
last_message_at?: string | null;
created_at: string;
unread: number;
/** Set while muted. Unread still accrues — mute silences alerts only. */
muted_until?: string | null;
peer?: ChatPeer | null;
last_message?: MessagePreview | null;
};
/** A formatting span. Offsets are UTF-16 code units, matching String.slice. */
export type MessageEntity = {
type: "bold" | "strike" | "code" | "url";
offset: number;
length: number;
url?: string;
};
/** The quoted snippet shown above a reply. */
export type ReplyPreview = {
id: string;
seq: number;
sender_id?: string | null;
sender_name: string;
body: string;
deleted: boolean;
};
export type ChatMessage = {
id: string;
conversation_id: string;
seq: number;
sender_id?: string | null;
kind: "text" | "system" | "assistant";
body: string;
entities: MessageEntity[];
client_msg_id?: string | null;
reply_to_id?: string | null;
forwarded_from_id?: string | null;
edited_at?: string | null;
deleted_at?: string | null;
created_at: string;
/** Present when this message replies to another in the same thread. */
reply_to?: ReplyPreview | null;
/** Always an array — media, link cards and shared entities. */
attachments: Attachment[];
/**
* Client-only. Set on a message shown optimistically before the server has
* acknowledged it; never sent by the BFF.
*/
pending?: boolean;
/**
* Client-only. Set when the send failed. The bubble stays put so the text
* isn't lost and the user can retry — retrying reuses the same
* `client_msg_id`, which is what makes it safe if the original actually
* landed.
*/
failed?: boolean;
};
/** One thread plus who is in it — everything the thread screen needs. */
export type ConversationDetail = {
id: string;
kind: ConversationKind;
title?: string | null;
avatar_key?: string | null;
send_policy: SendPolicy;
/** The caller's own role, so admin controls can be shown without deriving it. */
my_role: GroupRole;
last_seq: number;
last_message_at?: string | null;
created_at: string;
peer?: ChatPeer | null;
participants: ChatPeer[];
unread: number;
muted_until?: string | null;
/** DMs only — a group has no single peer to stand in a relationship with. */
relationship?: ChatRelationship | null;
};
/** Link preview metadata. Always fetched by the BFF, never composed here. */
export type LinkPreview = {
url: string;
title?: string;
description?: string;
site_name?: string;
image_url?: string;
};
export type AttachmentKind =
| "image"
| "video"
| "audio"
| "file"
| "link"
| "entity";
export type Attachment = {
id: string;
message_id: string;
ord: number;
kind: AttachmentKind;
mime?: string | null;
size_bytes?: number | null;
width?: number | null;
height?: number | null;
duration_ms?: number | null;
blurhash?: string | null;
file_name?: string | null;
url?: string | null;
title?: string | null;
description?: string | null;
site_name?: string | null;
image_url?: string | null;
};
/** How two people in a DM stand with each other. */
export type ChatRelationship = {
following: boolean;
followed_by: boolean;
blocked: boolean;
blocked_by: boolean;
/** When the first follow between them was accepted. */
friends_since?: string | null;
};
export type Receipts = { delivered_seq: number; read_seq: number };
export type MuteDuration = "8h" | "1w" | "always" | "off";
/** Who may START a conversation with you. */
export type WhoCanMessage =
| "anyone"
| "friends"
| "account_age"
| "same_gender";
export type ChatSettings = {
read_receipts: boolean;
who_can_message: WhoCanMessage;
min_account_age_months: number;
train_kautilya: boolean;
/** Whether a gender is set on the profile — same_gender needs one. */
gender_set: boolean;
};
export type InboxTab = "chats" | "kautilya";
// ─── API ─────────────────────────────────────────────────────────────────
export const chat = {
inbox: (tab: InboxTab) =>
call<InboxItem[]>(`/chat/conversations?tab=${tab}`),
/**
* Opens (or returns) the DM with a profile. Idempotent, and keyed by handle
* because that is what a profile page has.
*/
openDM: (handle: string) =>
call<{ id: string; kind: ConversationKind }>("/chat/conversations/dm", {
method: "POST",
body: JSON.stringify({ handle }),
}),
createGroup: (title: string, handles: string[]) =>
call<{ id: string }>("/chat/conversations/group", {
method: "POST",
body: JSON.stringify({ title, member_handles: handles }),
}),
detail: (conversationId: string) =>
call<ConversationDetail>(`/chat/conversations/${conversationId}`),
messages: (conversationId: string, beforeSeq = 0, limit = 30) =>
call<ChatMessage[]>(
`/chat/conversations/${conversationId}/messages?before_seq=${beforeSeq}&limit=${limit}`,
),
send: (
conversationId: string,
body: string,
opts?: {
clientMsgId?: string;
replyToId?: string;
previewUrl?: string;
attachments?: UploadedAttachment[];
},
) =>
call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, {
method: "POST",
body: JSON.stringify({
body,
client_msg_id: opts?.clientMsgId ?? null,
reply_to_id: opts?.replyToId ?? null,
// Only the URL: the BFF fetches the card's contents itself.
preview_url: opts?.previewUrl ?? null,
attachments: opts?.attachments ?? [],
}),
}),
/**
* Members are named by handle, not id: search results and follow lists
* identify people by handle, and it matches /u/{handle}/block elsewhere.
*/
addMembers: (conversationId: string, handles: string[]) =>
call<void>(`/chat/conversations/${conversationId}/members`, {
method: "POST",
body: JSON.stringify({ member_handles: handles }),
}),
/** Passing your own handle is how you leave. */
removeMember: (conversationId: string, handle: string) =>
call<void>(
`/chat/conversations/${conversationId}/members/${encodeURIComponent(handle)}`,
{ method: "DELETE" },
),
setMemberRole: (conversationId: string, handle: string, role: GroupRole) =>
call<void>(
`/chat/conversations/${conversationId}/members/${encodeURIComponent(handle)}`,
{ method: "PATCH", body: JSON.stringify({ role }) },
),
updateGroup: (
conversationId: string,
patch: { title?: string; send_policy?: SendPolicy; avatar_key?: string },
) =>
call<void>(`/chat/conversations/${conversationId}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
settings: () => call<ChatSettings>("/chat/settings"),
/** Partial: anything omitted keeps its current value. */
updateSettings: (patch: Partial<Omit<ChatSettings, "gender_set">>) =>
call<ChatSettings>("/chat/settings", {
method: "PATCH",
body: JSON.stringify(patch),
}),
gallery: (conversationId: string, tab: "media" | "links", before?: string) =>
call<Attachment[]>(
`/chat/conversations/${conversationId}/gallery?tab=${tab}` +
(before ? `&before=${encodeURIComponent(before)}` : ""),
),
starred: (conversationId: string) =>
call<ChatMessage[]>(`/chat/conversations/${conversationId}/starred`),
deleteMessage: (
conversationId: string,
messageId: string,
scope: "me" | "everyone",
) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}?scope=${scope}`,
{ method: "DELETE" },
),
/** The path names the destination; the body names the message copied in. */
forward: (toConversationId: string, messageId: string) =>
call<ChatMessage>(`/chat/conversations/${toConversationId}/forward`, {
method: "POST",
body: JSON.stringify({ message_id: messageId }),
}),
report: (
conversationId: string,
messageId: string,
category: string,
reason: string,
) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/report`,
{ method: "POST", body: JSON.stringify({ category, reason }) },
),
setStar: (conversationId: string, messageId: string, starred: boolean) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/star`,
{ method: starred ? "POST" : "DELETE" },
),
presignGroupAvatar: (conversationId: string, contentType: string) =>
call<{ upload_url: string; key: string }>(
`/chat/conversations/${conversationId}/avatar/presign`,
{ method: "POST", body: JSON.stringify({ content_type: contentType }) },
),
presignAttachment: (conversationId: string, contentType: string) =>
call<{ upload_url: string; key: string }>(
`/chat/conversations/${conversationId}/attachments/presign`,
{ method: "POST", body: JSON.stringify({ content_type: contentType }) },
),
/** Metadata for a link the user is typing, so they can dismiss the card. */
linkPreview: (url: string) =>
call<LinkPreview>("/chat/link-preview", {
method: "POST",
body: JSON.stringify({ url }),
}),
markRead: (conversationId: string, seq: number) =>
call<void>(`/chat/conversations/${conversationId}/read`, {
method: "POST",
body: JSON.stringify({ seq }),
}),
markDelivered: (conversationId: string, seq: number) =>
call<void>(`/chat/conversations/${conversationId}/delivered`, {
method: "POST",
body: JSON.stringify({ seq }),
}),
receipts: (conversationId: string) =>
call<Receipts>(`/chat/conversations/${conversationId}/receipts`),
setMute: (conversationId: string, duration: MuteDuration) =>
call<{ muted_until: string | null }>(
`/chat/conversations/${conversationId}/mute`,
{ method: "POST", body: JSON.stringify({ duration }) },
),
};
/** A file already uploaded to the private bucket, ready to attach on send. */
export type UploadedAttachment = {
key: string;
mime: string;
size_bytes?: number;
width?: number;
height?: number;
file_name?: string;
};
/** Natural dimensions of an image file, or nothing if it can't be decoded. */
async function imageSize(
file: File,
): Promise<{ width: number; height: number } | null> {
if (!file.type.startsWith("image/")) return null;
try {
const bitmap = await createImageBitmap(file);
const size = { width: bitmap.width, height: bitmap.height };
bitmap.close();
return size;
} catch {
return null;
}
}
/**
* Uploads a file straight to the private bucket and returns what `send` needs.
*
* The bytes never touch the BFF — it only signs the URL — which is the same
* path avatars and post images already take. Dimensions travel with the
* attachment so the bubble can reserve the right space before the image
* loads, instead of reflowing the thread when it arrives.
*/
export async function uploadAttachment(
conversationId: string,
file: File,
): Promise<UploadedAttachment> {
const size = await imageSize(file);
const { upload_url, key } = await chat.presignAttachment(
conversationId,
file.type,
);
const res = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!res.ok) {
throw new ChatError(res.status, "Upload failed");
}
return {
key,
mime: file.type,
size_bytes: file.size,
width: size?.width,
height: size?.height,
file_name: file.name,
};
}
/**
* Stable URL for a group's photo. Like message attachments it redirects to a
* short-lived signed URL after re-checking membership, so the object itself
* stays private. `v` busts the browser cache when the photo changes.
*/
export function groupAvatarUrl(
conversationId: string,
avatarKey?: string | null,
): string | undefined {
if (!avatarKey) return undefined;
return `${BFF_BASE}/api/v1/chat/conversations/${conversationId}/avatar?v=${encodeURIComponent(
avatarKey.slice(-12),
)}`;
}
/**
* Uploads a new group photo and returns its key for `updateGroup`. Compressed
* first, which also strips EXIF.
*/
export async function uploadGroupAvatar(
conversationId: string,
file: File,
): Promise<string> {
const { upload_url, key } = await chat.presignGroupAvatar(
conversationId,
file.type,
);
const res = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!res.ok) throw new ChatError(res.status, "Upload failed");
return key;
}
/** MIME types the BFF accepts as chat attachments. */
export const ACCEPTED_ATTACHMENTS = "image/*,video/*,audio/*,application/pdf";
/**
* Stable URL for a chat attachment. The BFF re-checks membership and redirects
* to a short-lived signed URL, so this can be cached in markup safely while the
* underlying object stays private.
*/
export function attachmentUrl(attachmentId: string): string {
return `${BFF_BASE}/api/v1/chat/attachments/${attachmentId}`;
}
/** First http(s) URL in a string, or null. Mirrors the server's matcher. */
export function firstUrl(text: string): string | null {
const m = text.match(/(?:https?:\/\/|www\.)[^\s<>()[\]{}"']+/i);
if (!m) return null;
const raw = m[0].replace(/[.,;:!?'"]+$/, "");
return raw.includes("://") ? raw : `https://${raw}`;
}
/** Whether a role may administer a group. */
export function isGroupAdmin(role?: GroupRole | null): boolean {
return role === "owner" || role === "admin";
}
/** True while `muted_until` is in the future. */
export function isMuted(item: Pick<InboxItem, "muted_until">): boolean {
return !!item.muted_until && new Date(item.muted_until).getTime() > Date.now();
}
/** Display name for a thread: the peer for a DM, the title otherwise. */
export function conversationTitle(
item: Pick<InboxItem, "kind" | "peer" | "title">,
): string {
if (item.kind === "ai") return "Kautilya";
if (item.kind === "dm") return item.peer?.name || "Unknown";
return item.title || "Group";
}
/**
* How to describe a DM's follow state in one line. Deliberately explicit about
* one-way follows rather than collapsing everything into "friends".
*/
export function followSummary(
rel: ChatRelationship | null | undefined,
peerName: string,
): string {
if (!rel) return "";
if (rel.following && rel.followed_by) return "You follow each other";
if (rel.following) return `You follow ${peerName}`;
if (rel.followed_by) return `${peerName} follows you`;
return "You don't follow each other";
}
/** Resolves a sender id to a display name using a thread's participants. */
export function senderName(
senderId: string | null | undefined,
participants: ChatPeer[],
selfId: string,
): string {
if (!senderId) return "Kautilya";
if (senderId === selfId) return "You";
return participants.find((p) => p.id === senderId)?.name ?? "Unknown";
}
+49
View File
@@ -41,6 +41,19 @@ export type YearGroup = {
export type SuffixFilter = "all" | "Dasa" | "Devi Dasi"; export type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
/**
* A disciple who has a platform account, linked by an admin.
*
* The disciple records live in Mongo while accounts live in Postgres, so the
* link is resolved server-side by the BFF and handed over as this flat pair.
*/
export type DiscipleLink = {
discipleId: string;
handle: string;
name: string;
avatarURL: string | null;
};
// --- BFF response types (snake_case from Go API) --- // --- BFF response types (snake_case from Go API) ---
type BffDisciple = { type BffDisciple = {
@@ -76,6 +89,42 @@ function mapDisciple(d: BffDisciple): DiscipleSummary {
}; };
} }
// --- Linked platform accounts ---
type BffDiscipleLink = {
disciple_id: string;
handle: string;
name: string;
avatar_url: string | null;
};
/**
* Get every disciple who has a platform account.
*
* Deliberately unpaged — only Śrīla Prabhupāda's own disciples are ever
* linked, so this is a few dozen rows at most and the browser fetches the
* whole set once to highlight the matching cards.
*
* Fails soft: the highlight is decorative, so a BFF hiccup (or a rolling
* deploy where the frontend is ahead of the API) must not take the whole
* directory page down with it. `bff` already maps 404 → null; here we also
* swallow 5xx and network errors and fall back to "no highlights".
*/
export async function getDiscipleLinks(): Promise<DiscipleLink[]> {
try {
const links = await bffList<BffDiscipleLink>("/disciples/linked");
return links.map((l) => ({
discipleId: l.disciple_id,
handle: l.handle,
name: l.name,
avatarURL: l.avatar_url ?? null,
}));
} catch (err) {
console.error("getDiscipleLinks failed; rendering without highlights", err);
return [];
}
}
// --- Count --- // --- Count ---
/** Get total number of disciples in the collection. */ /** Get total number of disciples in the collection. */
+54
View File
@@ -0,0 +1,54 @@
/**
* Realtime channel authorization.
*
* Our Soketi channels are `private-`/`presence-` prefixed, so Soketi refuses a
* subscription without a signature. Only the BFF holds the app secret, so the
* client asks it to sign each subscription at
* POST /api/v1/broadcasting/auth — which first checks the session cookie
* identifies someone allowed on that channel.
*
* We supply a `customHandler` rather than pusher-js's built-in `authEndpoint`
* because that built-in uses XMLHttpRequest without setting
* `withCredentials`, so it would never send our httpOnly session cookie
* cross-origin (prabhupadasays.org → api.prabhupadasays.org) and every
* subscription would 401. A plain fetch with `credentials: "include"` matches
* how the rest of lib/ talks to the BFF.
*
* The endpoint answers with a BARE {auth, channel_data} body rather than the
* usual {success, data} envelope, because pusher-js reads `auth` off the top
* level of the response.
*/
import type { ChannelAuthorizationHandler } from "pusher-js";
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
/**
* Signs private/presence subscriptions. Shared by every realtime consumer, so
* new channel families need no client-side change — only a rule on the BFF.
*/
export const channelAuthorizer: ChannelAuthorizationHandler = (
{ socketId, channelName },
callback,
) => {
fetch(`${BFF_BASE}/api/v1/broadcasting/auth`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ socket_id: socketId, channel_name: channelName }),
})
.then(async (res) => {
if (!res.ok) {
// 403 is a normal outcome (not a participant / not your channel), so
// carry the status rather than presenting it as a network fault.
throw new Error(
`channel authorization failed for ${channelName}: ${res.status}`,
);
}
return (await res.json()) as { auth: string; channel_data?: string };
})
.then((data) => callback(null, data))
.catch((err: unknown) => {
callback(err instanceof Error ? err : new Error(String(err)), null);
});
};
+18 -1
View File
@@ -168,6 +168,13 @@ export type RelationshipState = {
blocked: boolean; blocked: boolean;
/** they have blocked you. */ /** they have blocked you. */
blocked_by: boolean; blocked_by: boolean;
/** Whether the viewer may START a conversation with this profile. Reflects
* the target's "who can message you" setting and any block, so the Message
* button is only offered when tapping it would actually work. */
can_message: boolean;
/** The target's rule when `can_message` is false — one of "blocked",
* "friends", "account_age", "same_gender" — so the UI can explain why. */
message_restriction?: string;
}; };
/** Returned by every relationship mutation: the recomputed state plus /** Returned by every relationship mutation: the recomputed state plus
@@ -182,6 +189,9 @@ export type PublicProfile = {
handle: string; handle: string;
name: string; name: string;
avatar_url: string | null; avatar_url: string | null;
/** Short self-introduction (≤200 chars), plain text with `@handle`
* mentions linkified at render time. null when unset. */
bio: string | null;
joined_at: string; joined_at: string;
/** ISO-3166-1 alpha-2 account country, or null when unknown. */ /** ISO-3166-1 alpha-2 account country, or null when unknown. */
country: string | null; country: string | null;
@@ -192,6 +202,9 @@ export type PublicProfile = {
follower_count: number; follower_count: number;
following_count: number; following_count: number;
relationship: RelationshipState | null; relationship: RelationshipState | null;
/** True when an admin has linked this account to the disciples directory —
* i.e. one of Śrīla Prabhupāda's own initiated disciples. */
is_direct_disciple?: boolean;
public_collections: Collection[]; public_collections: Collection[];
}; };
@@ -698,7 +711,11 @@ export const notifications = {
// ─── Notification preferences ────────────────────────────────────────── // ─── Notification preferences ──────────────────────────────────────────
export type PrefEvent = "follows" | "post_interactions" | "sadhna_reminders"; export type PrefEvent =
| "follows"
| "post_interactions"
| "sadhna_reminders"
| "community";
export type PrefChannel = "in_app" | "email" | "push"; export type PrefChannel = "in_app" | "email" | "push";
/** event → channel → enabled, as resolved by the BFF (defaults on). */ /** event → channel → enabled, as resolved by the BFF (defaults on). */
export type NotificationPrefs = Record<PrefEvent, Record<PrefChannel, boolean>>; export type NotificationPrefs = Record<PrefEvent, Record<PrefChannel, boolean>>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB