31 Commits
Author SHA1 Message Date
gosangamandClaude Opus 5.5 f855699e72 Quizzes: To do = started but unfinished; durations roll into days
The To do tab now lists only quizzes you've started and not finished;
untouched ones stay under All. Quiz times show hours and days
(3h 20m, 2d 4h) instead of ever-growing minutes, and 59.96s no longer
prints as "60.0s".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:05 +05:30
gosangamandClaude Opus 5.5 52cd48b12e Feedback form: load categories from the BFF
The form offers the admin-managed categories, falling back to the original
four if they can't be loaded. "My feedback" shows the server's labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:47:09 +05:30
gosangamandClaude Opus 5.5 0a23ab59f7 Quiz list: drill down by Books → book and Lectures → year
The All tab is now a click-through of category cards — Books, Lectures &
Conversations, Other quizzes — each opening its books or years, then the
quizzes themselves. Only categories with quizzes get a card. The position
and tab live in the URL (?in=books&book=bg, ?tab=todo), so refresh, Back
and shared links keep your place. Book quizzes list in chapter order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:23:46 +05:30
gosangam 3af4e1bcd3 quiz grouping 2026-10-18 13:02:30 +05:30
gosangam fed12b0d46 ui fix 2026-10-17 02:41:32 +05:30
gosangam d64485daa4 remove confetti 2026-10-16 20:04:25 +05:30
gosangam 0968b6046d feat: quiz confetti 2026-10-16 15:27:46 +05:30
gosangam 8dd72de359 quiz link 2026-10-16 03:25:08 +05:30
gosangam 8537eb6ce2 audio player respects group context 2026-10-15 23:18:05 +05:30
gosangam 09dc734613 mobile responsive 2026-10-15 22:29:17 +05:30
gosangam 5832125ab2 quiz btn styling update 2026-10-15 22:14:34 +05:30
gosangam 0e2e0d7300 quiz seo image and authguard 2026-10-15 21:09:37 +05:30
gosangam 6a6143ceb2 feat: quiz 2026-10-15 20:07:01 +05:30
gosangam 9f67da37a6 feedback formatter 2026-10-09 06:40:28 +05:30
gosangam cce8a4c05f share to chat fix 2026-09-02 00:15:31 +05:30
gosangam b391e1c575 fix: about us page in legal 2026-08-30 11:16:33 +05:30
gosangam 3ce08428c8 fix: notification matrix 2026-08-30 11:15:54 +05:30
gosangam bf8db28a7c feat: chat search 2026-08-30 10:57:28 +05:30
gosangam cc9ea45c61 feat: sticker preview 2026-08-30 10:54:56 +05:30
gosangam 7a9d362b15 fix: chat UI fix for media 2026-08-30 03:12:43 +05:30
gosangam c001eeab45 fix: stickers and chat UI fix 2026-08-30 03:01:20 +05:30
gosangam 2b0f966f90 fix: chat bubble text unselectable 2026-08-30 02:28:31 +05:30
gosangam 2597c6f2ec feat: chat stickers 2026-08-30 02:19:31 +05:30
gosangam 8c7afe596d feat: chat reactions 2026-08-30 01:51:52 +05:30
gosangam 0f382ed885 fix: no attachment broadcast fix 2026-08-29 18:56:45 +05:30
gosangam 03c8799f22 feat: broadcast 2026-08-29 18:34:21 +05:30
gosangam 011b5bba91 fix: chat UI 2026-08-29 17:30:32 +05:30
gosangam 3e6a1c6f39 fix: chat accepts posts 2026-08-29 16:26:53 +05:30
gosangam 76cd106ef7 fix: chat accepts quotes now 2026-08-29 11:58:33 +05:30
gosangam 028a8d7e1c fix: chat UI fixes 2026-08-29 04:12:30 +05:30
gosangam 290ceb048f fix: chat URL fix in bottom nav 2026-08-29 04:02:07 +05:30
85 changed files with 5174 additions and 623 deletions
@@ -9,6 +9,7 @@ import {
} from "@/lib/transcriptions";
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { QuizButton } from "@/components/quiz/quiz-button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
@@ -95,6 +96,9 @@ export default async function TranscriptionPage({
/>
<PageHeader
sticky
// With audio, the quiz button sits beside the play control below
// instead; without it, the header shows it as on any other page.
quiz={!t.audioUrl}
offline={{
type: "transcript",
title: t.name,
@@ -168,13 +172,15 @@ export default async function TranscriptionPage({
{/* Play Audio button */}
{t.audioUrl && (
<div className="w-full md:w-[25%] flex justify-end">
<div className="w-full md:w-auto flex flex-wrap items-center justify-end gap-2">
<QuizButton path={`/transcriptions/${t.slug}`} size="lg" />
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
sourceUrl={`/transcriptions/${t.slug}`}
type={t.type}
fallbackYear={t.year}
/>
</div>
)}
@@ -5,6 +5,11 @@ import {
type AudioTrack,
AudioWaveAnimation,
} from "@/components/audio-player";
import {
rememberedQueue,
useTranscriptQueue,
yearQueue,
} from "@/components/audio-player/transcript-queue";
import { cn } from "@/lib/utils";
import { Play } from "lucide-react";
@@ -17,6 +22,11 @@ type PlayAudioButtonProps = {
className?: string;
/** Compact icon-only variant for list items */
variant?: "default" | "compact";
/**
* On a lecture's own page: its year, queued as a fallback when the lecture
* wasn't opened from a list (search, a shared link…).
*/
fallbackYear?: number | null;
};
export function PlayAudioButton({
@@ -27,8 +37,18 @@ export function PlayAudioButton({
type,
className,
variant = "default",
fallbackYear,
}: PlayAudioButtonProps) {
const { currentTrack, isPlaying, play, togglePlayPause } = useAudioPlayer();
const {
currentTrack,
isPlaying,
play,
playPlaylist,
attachQueue,
togglePlayPause,
} = useAudioPlayer();
// The list this button sits in (a transcription list row), if any.
const listQueue = useTranscriptQueue();
const isThisTrack = currentTrack?.url === audioUrl;
const isThisPlaying = isThisTrack && isPlaying;
@@ -44,7 +64,25 @@ export function PlayAudioButton({
sourceUrl,
type: type ?? "lecture",
};
// Queue the list this recording was found in, so Next / Previous
// follow it: the list the button sits in, or — on a lecture's own
// page — the list it was opened from.
const queue =
listQueue ??
rememberedQueue(sourceUrl.replace(/^\/transcriptions\//, ""));
const at = queue?.findIndex((q) => q.url === audioUrl) ?? -1;
if (queue && at >= 0 && queue.length > 1) {
playPlaylist(queue, at);
return;
}
// No list to follow: start this lecture now — audio must begin inside
// the tap, or iOS blocks it — then queue its year once that loads.
play(track);
if (fallbackYear) {
void yearQueue(fallbackYear).then((tracks) => {
if (tracks && tracks.length > 1) attachQueue(tracks);
});
}
}
}
@@ -7,7 +7,15 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu, Calendar, Clock, MapPin } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Menu,
Calendar,
Clock,
MapPin,
} from "lucide-react";
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
import { parseChapter, parseVerse } from "@/lib/book-reference";
import {
@@ -417,6 +425,13 @@ export function TranscriptionBookBrowser({
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
// Next / Previous run through every chapter's lectures in display order,
// so playback carries on into the next chapter.
const queueItems = useMemo(
() => flat.flatMap((f) => f.chapter.transcriptions),
[flat],
);
const activeIdx = flat.findIndex((f) => f.chapter.key === activeKey);
const active = activeIdx >= 0 ? flat[activeIdx] : null;
const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
@@ -463,6 +478,7 @@ export function TranscriptionBookBrowser({
{/* Main content */}
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
<TranscriptQueue items={queueItems}>
{/* Top navigation */}
<ChapterNav
prev={prev}
@@ -499,6 +515,7 @@ export function TranscriptionBookBrowser({
onSelect={handleSelect}
drawerTitle={title}
/>
</TranscriptQueue>
</main>
</div>
</>
@@ -9,6 +9,7 @@ import { PageHeader } from "@/components/layout/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Calendar, Clock } from "lucide-react";
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
export async function generateMetadata({
@@ -55,6 +56,8 @@ export default async function TranscriptionsByPlacePage({
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
{/* Next / Previous in the player follow this list. */}
<TranscriptQueue items={transcriptions}>
<Card className="overflow-hidden">
<CardContent className="p-0 divide-y divide-border">
{transcriptions.map((t) => (
@@ -107,6 +110,7 @@ export default async function TranscriptionsByPlacePage({
))}
</CardContent>
</Card>
</TranscriptQueue>
</main>
</div>
);
@@ -14,6 +14,7 @@ import {
Clock,
MapPin,
} from "lucide-react";
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
import {
Drawer,
@@ -481,6 +482,16 @@ export function TranscriptionTypeBrowser({
[activeGroup],
);
// Next / Previous run through the whole list in display order — year by
// year, month by month — so playback carries on past the year on screen.
const queueItems = useMemo(
() =>
yearGroups.flatMap((yg) =>
buildMonthGroups(yg.transcriptions).flatMap((mg) => mg.transcriptions),
),
[yearGroups],
);
const activeYearLabel = activeGroup?.label ?? "Unknown";
const drawerTitle = typeLabel;
@@ -520,6 +531,7 @@ export function TranscriptionTypeBrowser({
{/* Main content */}
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
<TranscriptQueue items={queueItems}>
{/* Top navigation */}
<YearNav
prev={prev}
@@ -560,6 +572,7 @@ export function TranscriptionTypeBrowser({
onSelect={handleYearSelect}
drawerTitle={drawerTitle}
/>
</TranscriptQueue>
</main>
</div>
</>
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react";
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
type TranscriptionItem = {
@@ -169,6 +170,12 @@ export function TranscriptionYearBrowser({
return groups;
}, [transcriptions]);
// Display order — month by month — is the order Next / Previous play in.
const queueItems = useMemo(
() => monthGroups.flatMap((g) => g.items),
[monthGroups],
);
const lectureCount = transcriptions.filter(
(t) => t.type === "lecture",
).length;
@@ -202,6 +209,7 @@ export function TranscriptionYearBrowser({
</p>
</div>
) : (
<TranscriptQueue items={queueItems}>
<Card className="overflow-hidden">
<CardContent className="p-0 divide-y divide-border">
{monthGroups.map((mg, i) => (
@@ -215,6 +223,7 @@ export function TranscriptionYearBrowser({
))}
</CardContent>
</Card>
</TranscriptQueue>
)}
</main>
</>
+189 -13
View File
@@ -30,21 +30,31 @@ import {
type MenuPoint,
} from "@/components/chat/message-menu";
import { ForwardDialog } from "@/components/chat/forward-dialog";
import { ReactionsDrawer } from "@/components/chat/reactions-drawer";
import { toast } from "sonner";
import { channelAuthorizer } from "@/lib/realtime";
import { cn } from "@/lib/utils";
import { useVisualViewport } from "@/lib/use-visual-viewport";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { useNotifications } from "@/components/notifications/notifications-provider";
import {
chat,
conversationTitle,
groupAvatarUrl,
isMuted,
senderName,
senderPeer,
type ChatMessage,
type ConversationDetail,
type Receipts,
type UploadedAttachment,
} from "@/lib/chat";
/** How long a typing indicator survives its last ping. */
const TYPING_TTL_MS = 5000;
/** How often we tell the thread we are typing, while typing. */
const TYPING_PING_MS = 3000;
const PAGE_SIZE = 30;
export default function ChatThreadPage() {
@@ -62,7 +72,22 @@ export default function ChatThreadPage() {
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
// Non-null asks before deleting for everyone. Deleting only for yourself is
// reversible by the others still having it, so it goes straight through.
const [confirmDelete, setConfirmDelete] = useState<ChatMessage | null>(null);
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
// user id → when they last typed. Kept as timestamps rather than a boolean
// so the indicator expires on its own: there is no "stopped typing" event,
// and inventing one would still be lost on a dropped connection.
const [typingAt, setTypingAt] = useState<Record<string, number>>({});
// While the keyboard is up the thread is sized from the visual viewport
// instead of dvh — see useVisualViewport for why dvh can't do this on iOS.
// Declared with the other hooks: the early return below means anything
// placed after it would run conditionally.
const { refreshChatUnread } = useNotifications();
const viewport = useVisualViewport();
const keyboardOpen = viewport?.keyboardOpen ?? false;
// Guards against two loads racing (scroll handler + the short-page effect).
const loadingRef = useRef(false);
@@ -147,6 +172,7 @@ export default function ChatThreadPage() {
replyToId?: string,
previewUrl?: string,
attachments?: UploadedAttachment[],
stickerKey?: string,
) => {
const clientMsgId = crypto.randomUUID();
const optimistic: ChatMessage = {
@@ -160,13 +186,20 @@ export default function ChatThreadPage() {
client_msg_id: clientMsgId,
reply_to_id: replyToId ?? null,
attachments: [],
reactions: [],
created_at: new Date().toISOString(),
pending: true,
};
setMessages((prev) => [...prev, optimistic]);
chat
.send(id, body, { clientMsgId, replyToId, previewUrl, attachments })
.send(id, body, {
clientMsgId,
replyToId,
previewUrl,
attachments,
stickerKey,
})
.then((saved) => {
setMessages((prev) => {
// Drop the placeholder and any realtime echo of the same message.
@@ -266,12 +299,6 @@ export default function ChatThreadPage() {
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") {
@@ -311,12 +338,100 @@ export default function ChatThreadPage() {
);
/** Mark everything visible as read, so the sender's ticks turn blue. */
// Expire stale typers. A ticking clock rather than a timer per person: one
// interval regardless of how many are in the room, and it stops entirely
// once nobody is typing.
const [now, setNow] = useState(0);
useEffect(() => {
if (Object.keys(typingAt).length === 0) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [typingAt]);
// Ids only. Resolving names here would call nameFor before it is declared
// further down — a temporal-dead-zone crash that TypeScript does not catch,
// because the call would sit inside a .map callback.
// `now || Date.now()` keeps the first render correct while still depending
// on the ticking clock above.
const typingIds = Object.entries(typingAt)
.filter(([, at]) => (now || Date.now()) - at < TYPING_TTL_MS)
.map(([uid]) => uid);
/** Tell the thread we are composing, at most once every few seconds. */
const typingSentAt = useRef(0);
const notifyTyping = useCallback(() => {
const t = Date.now();
if (t - typingSentAt.current < TYPING_PING_MS) return;
typingSentAt.current = t;
chat.typing(id).catch(() => {});
}, [id]);
/** Replace one message in place, after its reactions changed. */
const refreshMessage = useCallback(
async (messageId: string) => {
try {
const fresh = await chat.message(id, messageId);
setMessages((prev) =>
prev.map((m) => (m.id === messageId ? { ...m, ...fresh } : m)),
);
} catch {
/* a stale chip is not worth an error surface */
}
},
[id],
);
/**
* Set or toggle a reaction. Picking the emoji you already chose removes it,
* which is what makes the chip and the double-tap heart both behave as
* toggles.
*/
const react = useCallback(
async (message: ChatMessage, emoji: string) => {
const mine = message.reactions?.find((r) => r.mine)?.emoji;
try {
if (mine === emoji) await chat.unreact(id, message.id);
else await chat.react(id, message.id, emoji);
// The server publishes a `reaction` event, but the actor is on the
// same channel and would otherwise wait a round trip to see it.
await refreshMessage(message.id);
} catch {
toast.error("Could not react");
}
},
[id, refreshMessage],
);
// Which message's reaction list is open, if any.
const [reactionsFor, setReactionsFor] = useState<string | null>(null);
/**
* The trail does one of two things, never both: retract your reaction if
* you have one, otherwise show who reacted. Rolling both into one control
* keeps the bubble uncluttered, at the cost that seeing the list means
* removing your own reaction first.
*/
const onTrailClick = useCallback(
(message: ChatMessage) => {
const mine = message.reactions?.find((r) => r.mine)?.emoji;
if (mine) void react(message, mine);
else setReactionsFor(message.id);
},
[react],
);
const markRead = useCallback(
(seq: number) => {
if (!seq) return;
chat.markRead(id, seq).catch(() => {});
// Refresh the nav badge afterwards: without this it stays lit until the
// next incoming message, since nothing else tells it this thread was
// read.
chat
.markRead(id, seq)
.then(refreshChatUnread)
.catch(() => {});
},
[id],
[id, refreshChatUnread],
);
useEffect(() => {
@@ -381,6 +496,14 @@ export default function ChatThreadPage() {
),
);
});
channel.bind("reaction", ({ message_id }: { message_id: string }) => {
void refreshMessage(message_id);
});
channel.bind("typing", ({ user_id }: { user_id: string }) => {
// Ignore your own keystrokes echoing back off the channel.
if (!user_id || user_id === user?.id) return;
setTypingAt((prev) => ({ ...prev, [user_id]: Date.now() }));
});
channel.bind("receipt", () => {
chat.receipts(id).then(setReceipts).catch(() => {});
});
@@ -396,7 +519,7 @@ export default function ChatThreadPage() {
cancelled = true;
cleanup?.();
};
}, [id, user]);
}, [id, user, refreshMessage]);
if (loading || !user) {
return (
@@ -408,6 +531,10 @@ export default function ChatThreadPage() {
const nameFor = (senderId: string | null | undefined) =>
senderName(senderId, meta?.participants ?? [], user.id);
// Groups need the actual sender, not the conversation's `peer` — `peer` is
// only set on DMs, so a group has no single counterpart to read from.
const senderFor = (senderId: string | null | undefined) =>
senderPeer(senderId, meta?.participants ?? []);
const title = meta ? conversationTitle(meta) : "Chat";
const muted = meta ? isMuted(meta) : false;
@@ -429,8 +556,31 @@ export default function ChatThreadPage() {
// 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)]",
// Only while the keyboard is closed; otherwise the inline style below
// takes over and these would fight it.
// The full viewport: a thread hides the app's header AND its tab bar,
// so there is no longer any chrome to subtract.
!keyboardOpen && "h-[100dvh]",
)}
style={
keyboardOpen && viewport
? {
// Pinned to the visible strip above the keyboard. Fixed rather
// than in-flow because iOS scrolls the layout viewport instead
// of resizing it, so an in-flow column keeps its old height and
// hides the composer underneath. left/right 0 with the existing
// mx-auto keeps the desktop column centred.
position: "fixed",
top: viewport.offsetTop,
left: 0,
right: 0,
height: viewport.height,
// Above the app's fixed bottom nav (z-40) so the composer can
// never be painted over by it.
zIndex: 50,
}
: undefined
}
>
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
<Link
@@ -494,7 +644,7 @@ export default function ChatThreadPage() {
<MessageList
messages={messages}
selfId={user.id}
peer={meta?.peer}
senderFor={senderFor}
receipts={receipts}
isGroup={meta?.kind === "group"}
hasMore={hasMore}
@@ -505,6 +655,9 @@ export default function ChatThreadPage() {
starredIds={starredIds}
onRetry={retryMessage}
nameFor={nameFor}
typingNames={typingIds.map(nameFor).filter(Boolean)}
onReact={react}
onTrailClick={onTrailClick}
/>
)}
@@ -519,6 +672,7 @@ export default function ChatThreadPage() {
replyToName={nameFor(replyTo?.sender_id)}
onCancelReply={() => setReplyTo(null)}
onSend={sendMessage}
onTyping={notifyTyping}
disabled={state !== "ready"}
/>
)}
@@ -527,6 +681,8 @@ export default function ChatThreadPage() {
<MessageMenu
point={menu.point}
starred={starredIds.has(menu.message.id)}
myReaction={menu.message.reactions?.find((r) => r.mine)?.emoji}
onReact={(emoji) => react(menu.message, emoji)}
onClose={() => setMenu(null)}
actions={{
onReply: () => setReplyTo(menu.message),
@@ -537,7 +693,8 @@ export default function ChatThreadPage() {
// Only the sender may erase a message for everyone.
onDeleteForEveryone:
menu.message.sender_id === user.id
? () => void deleteMessage(menu.message, "everyone")
? // Ask first — the modal calls deleteMessage on confirm.
() => setConfirmDelete(menu.message)
: undefined,
// Reporting your own message is meaningless.
onReport:
@@ -548,6 +705,25 @@ export default function ChatThreadPage() {
/>
)}
{reactionsFor && (
<ReactionsDrawer
conversationId={id}
messageId={reactionsFor}
onClose={() => setReactionsFor(null)}
/>
)}
<ConfirmModal
open={confirmDelete !== null}
onClose={() => setConfirmDelete(null)}
title="Delete for everyone?"
message="This removes the message from the conversation for all participants. It can't be undone."
confirmLabel="Delete"
onConfirm={async () => {
if (confirmDelete) await deleteMessage(confirmDelete, "everyone");
}}
/>
{forwarding && (
<ForwardDialog
message={forwarding}
+74 -5
View File
@@ -16,14 +16,19 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { MessageCircle, Users } from "lucide-react";
import { MessageCircle, Search, Users, X } 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 {
chat,
conversationTitle,
type InboxItem,
type InboxTab,
} from "@/lib/chat";
import { cn } from "@/lib/utils";
/** How long a row stays highlighted after a message lands. */
@@ -36,6 +41,15 @@ export default function ChatsPage() {
const router = useRouter();
const [tab, setTab] = useState<InboxTab>("chats");
const [searching, setSearching] = useState(false);
const [query, setQuery] = useState("");
// Closing clears the term too: leaving a filter applied behind a collapsed
// field is how a chat goes missing and looks like a bug.
const closeSearch = useCallback(() => {
setSearching(false);
setQuery("");
}, []);
const [items, setItems] = useState<InboxItem[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [highlighted, setHighlighted] = useState<Set<string>>(new Set());
@@ -120,12 +134,57 @@ export default function ChatsPage() {
);
}
// Filtered here rather than on the server: the inbox is already loaded in
// full, so this is instant and keeps working offline. Matches the thread's
// name and, for a DM, the peer's handle — the two things you actually
// remember someone by.
const term = query.trim().toLowerCase();
const visible = term
? items.filter((item) => {
const title = conversationTitle(item).toLowerCase();
const handle = item.peer?.handle?.toLowerCase() ?? "";
return title.includes(term) || handle.includes(term);
})
: items;
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
{searching ? (
// The search field REPLACES the header rather than sitting under it:
// the title and New group are not useful while filtering, and stacking
// a second row would push the list down every time it opens.
<div className="flex items-center gap-2 rounded-lg border border-border 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 chats"
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={closeSearch}
aria-label="Close search"
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
) : (
<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>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setSearching(true)}
aria-label="Search chats"
className="inline-flex size-9 items-center justify-center rounded-lg border border-border text-foreground hover:bg-muted/60"
>
<Search className="size-4" />
</button>
{tab === "chats" && (
<button
type="button"
@@ -136,6 +195,8 @@ export default function ChatsPage() {
</button>
)}
</div>
</div>
)}
<div className="flex gap-1 rounded-lg bg-muted p-1">
<TabButton active={tab === "chats"} onClick={() => setTab("chats")}>
@@ -155,8 +216,16 @@ export default function ChatsPage() {
</p>
) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : items.length === 0 ? (
tab === "kautilya" ? (
) : visible.length === 0 ? (
// A search that matches nothing is NOT an empty inbox: saying "no
// chats yet" there reads as data loss rather than a narrow filter.
term ? (
<Empty
icon={<Search className="size-8" />}
title="No matches"
text={`Nothing in ${tab === "kautilya" ? "Kautilya" : "your chats"} matches “${query.trim()}”.`}
/>
) : tab === "kautilya" ? (
<Empty
icon={<KautilyaIcon className="size-8" />}
title="Kautilya is coming soon"
@@ -171,7 +240,7 @@ export default function ChatsPage() {
)
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((item) => (
{visible.map((item) => (
<ChatListItem
key={item.id}
item={item}
+43 -14
View File
@@ -9,23 +9,31 @@
* Authed visitors don't see the contact-email field — the BFF reads
* their account email from the session cookie. Anonymous visitors get
* the field and must fill it in so we have a reply path.
*
* Categories are managed in the admin panel and loaded from the BFF. If
* that request fails the form falls back to the original four, so it can
* always be submitted.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, MessageSquare, Send } from "lucide-react";
import { toast } from "sonner";
import { feedback as feedbackAPI, SocialError } from "@/lib/social";
import {
feedback as feedbackAPI,
SocialError,
type FeedbackCategory,
} from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
type Category = "bug" | "feature" | "general" | "other";
const CATEGORIES: { value: Category; label: string; description: string }[] = [
{ value: "general", label: "General feedback", description: "Thoughts, impressions, suggestions — anything else." },
{ value: "bug", label: "Bug report", description: "Something is broken or behaving unexpectedly." },
{ value: "feature", label: "Feature request", description: "An idea for something new on the site." },
{ value: "other", label: "Other", description: "Doesn't fit the above? Use this." },
// Used only if the categories can't be loaded — the original four, which
// the BFF seeds and keeps accepting unless an admin archives them.
const FALLBACK_CATEGORIES: FeedbackCategory[] = [
{ key: "general", label: "General feedback", description: "Thoughts, impressions, suggestions — anything else." },
{ key: "bug", label: "Bug report", description: "Something is broken or behaving unexpectedly." },
{ key: "feature", label: "Feature request", description: "An idea for something new on the site." },
{ key: "other", label: "Other", description: "Doesn't fit the above? Use this." },
];
export default function FeedbackPage() {
@@ -33,7 +41,28 @@ export default function FeedbackPage() {
const { user, loading } = useAuth();
const authed = !loading && !!user;
const [category, setCategory] = useState<Category>("general");
const [categories, setCategories] =
useState<FeedbackCategory[]>(FALLBACK_CATEGORIES);
// Empty until chosen: the first category on the form.
const [picked, setPicked] = useState("");
const category = categories.some((c) => c.key === picked)
? picked
: (categories[0]?.key ?? "");
useEffect(() => {
let cancelled = false;
feedbackAPI
.categories()
.then((list) => {
if (!cancelled && list?.length) setCategories(list);
})
.catch(() => {
/* keep the fallback list */
});
return () => {
cancelled = true;
};
}, []);
const [message, setMessage] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -72,7 +101,7 @@ export default function FeedbackPage() {
}
}
const selected = CATEGORIES.find((c) => c.value === category);
const selected = categories.find((c) => c.key === category);
return (
<main className="flex-1 max-w-xl w-full mx-auto px-4 sm:px-6 py-10 sm:py-14">
@@ -111,12 +140,12 @@ export default function FeedbackPage() {
<select
id="category"
value={category}
onChange={(e) => setCategory(e.target.value as Category)}
onChange={(e) => setPicked(e.target.value)}
className="mt-1.5 h-10 w-full rounded-lg border border-border bg-card px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
disabled={submitting}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
{categories.map((c) => (
<option key={c.key} value={c.key}>{c.label}</option>
))}
</select>
<p className="mt-1 text-xs text-muted-foreground">
+3 -2
View File
@@ -1,6 +1,7 @@
import { SiteHeader } from "@/components/layout/site-header";
import { SiteSidebar } from "@/components/layout/site-sidebar";
import { SiteBottomNav } from "@/components/layout/site-bottom-nav";
import { AppShell } from "@/components/layout/app-shell";
import { SiteFooter } from "@/components/layout/site-footer";
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
import { PushPrompt } from "@/components/push/push-prompt";
@@ -28,11 +29,11 @@ export default function AppLayout({
<>
<VerifyEmailBanner />
<SiteSidebar />
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
<AppShell>
<SiteHeader />
{children}
<SiteFooter />
</div>
</AppShell>
<SiteBottomNav />
<PushPrompt />
</>
+10 -3
View File
@@ -28,6 +28,7 @@ import {
type AppNotification,
type UserSummary,
} from "@/lib/social";
import { RichText } from "@/lib/rich-text";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { timeAgo } from "@/lib/time";
@@ -275,10 +276,16 @@ function NotificationRow({
<UserAvatar src={isAdmin ? null : n.actor_avatar_url} name={display} />
<div className="min-w-0 flex-1">
{isAdmin ? (
<p className="text-sm text-foreground leading-snug whitespace-pre-line">
// A div, not a <p>: the formatted body nests elements, and RichText
// handles line breaks itself (so no whitespace-pre-line either).
<div className="text-sm text-foreground leading-snug">
<span className="font-semibold">{display}</span>
{n.body ? <span className="block mt-0.5">{n.body}</span> : null}
</p>
{n.body ? (
<span className="block mt-0.5">
<RichText text={n.body} />
</span>
) : null}
</div>
) : (
<p className="text-sm text-foreground leading-snug">
<span className="font-semibold">{display}</span> {message}
+23 -6
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/auth/auth-provider";
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
import { VaniOfTheDayCTA } from "@/components/vani/vani-of-the-day-cta";
import { HomeFeed } from "@/components/feed/home-feed";
import { QuizzesCTA } from "@/components/quiz/quizzes-cta";
/**
* / — the Home (Feed) gate.
@@ -33,9 +34,17 @@ export default function HomePage() {
// mobile this max-width is wider than the viewport, so it's full-bleed
// as before.
<main className="flex-1 max-w-lg w-full mx-auto px-4 sm:px-6 py-6">
<div className="mb-6 flex flex-col gap-3">
<SadhnaStreakCTA />
<VaniOfTheDayCTA />
<div className="mb-6">
<div className="flex h-24 gap-3">
{/* A compact sādhana tile beside a wider Vāṇī of the Day: the
streak is a daily habit you already know how to reach, so it
needs only a recognisable mark; the vāṇī changes daily and
earns the room to say what it is. */}
<SadhnaStreakCTA compact />
<VaniOfTheDayCTA className="h-full min-w-0 max-w-none flex-1" />
</div>
{/* Only while something is left to take. */}
<QuizzesCTA pendingOnly className="mt-3" />
</div>
<HomeFeed />
</main>
@@ -88,9 +97,17 @@ export default function HomePage() {
</p>
{/* Daily sādhana streak + Vāṇī of the day — entry points for visitors. */}
<div className="mt-6 w-full max-w-md flex flex-col gap-3">
<SadhnaStreakCTA />
<VaniOfTheDayCTA />
<div className="mt-6 w-full max-w-md">
<div className="flex h-24 gap-3">
{/* A compact sādhana tile beside a wider Vāṇī of the Day: the
streak is a daily habit you already know how to reach, so it
needs only a recognisable mark; the vāṇī changes daily and
earns the room to say what it is. */}
<SadhnaStreakCTA compact />
<VaniOfTheDayCTA className="h-full min-w-0 max-w-none flex-1" />
</div>
{/* Only while something is left to take. */}
<QuizzesCTA pendingOnly className="mt-3" />
</div>
</div>
</main>
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { bff } from "@/lib/bff";
import { ogForQuiz } from "@/lib/og";
import type { QuizCard } from "@/lib/quiz";
import { QuizPlayer } from "./quiz-player";
type Props = { params: Promise<{ id: string }> };
/**
* Link previews name the quiz. The quiz's card is the BFF's one public quiz
* endpoint — title, description and what it's attached to, never questions —
* so a shared link unfurls even though taking the quiz needs an account.
*/
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const card = await bff<QuizCard>(`/quizzes/${encodeURIComponent(id)}`, {
revalidate: 300,
}).catch(() => null);
if (!card) return { title: "Quiz" };
return {
title: card.title,
description: `A quiz on Prabhupāda Says — ${card.question_count} question${card.question_count === 1 ? "" : "s"}.`,
...ogForQuiz(card),
};
}
export default async function QuizPage({ params }: Props) {
const { id } = await params;
return <QuizPlayer id={id} />;
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
/**
* Taking a quiz: an introduction, then one question at a time, then your
* result.
*
* Each question is fetched from the BFF as you reach it, and the BFF starts
* its clock at that moment — so response times are measured server-side and
* reloading the page doesn't restart them. Correct answers are only revealed
* on the results screen, never while you're still answering.
*
* Tapping an option only selects it; the answer is sent, and becomes final,
* when you press Submit. Multi questions ("select all that apply") take
* several selections.
*
* Signed in only: visitors get the sign-in modal over the page, and home if
* they dismiss it. Nothing about the quiz loads until they're signed in.
*/
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
ArrowLeft,
ArrowRight,
Check,
ClipboardCheck,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
import { RichText } from "@/lib/rich-text";
import {
quiz as quizApi,
quizActionLabel,
QuizError,
type QuizCard,
type QuizPlay,
type QuizResult,
} from "@/lib/quiz";
import { QuizResultView } from "@/components/quiz/quiz-result-view";
import { CopyPageLinkButton } from "@/components/layout/copy-page-link-button";
import { cn } from "@/lib/utils";
type Stage =
| { kind: "loading" }
| { kind: "intro"; card: QuizCard }
| { kind: "playing"; card: QuizCard; play: QuizPlay }
| { kind: "result"; card: QuizCard; result: QuizResult }
| { kind: "missing" };
export function QuizPlayer({ id }: { id: string }) {
const { user } = useSignedInPage();
const userId = user?.id;
const { requireAuth } = useAuthGate();
const [stage, setStage] = useState<Stage>({ kind: "loading" });
const [selected, setSelected] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
// Every new question starts with nothing selected.
const goTo = (card: QuizCard, play: QuizPlay) => {
setSelected([]);
setStage({ kind: "playing", card, play });
};
const toggle = (play: QuizPlay, optionId: string) => {
if (submitting) return;
setSelected((cur) =>
play.question?.multi
? cur.includes(optionId)
? cur.filter((x) => x !== optionId)
: [...cur, optionId]
: [optionId],
);
};
const showResult = useCallback(
async (card: QuizCard) => {
const result = await quizApi.result(id);
setStage({ kind: "result", card, result });
},
[id],
);
useEffect(() => {
if (!userId) return;
let cancelled = false;
quizApi
.get(id)
.then(async (card) => {
if (cancelled) return;
if (card.my.state === "completed") await showResult(card);
else setStage({ kind: "intro", card });
})
.catch(() => !cancelled && setStage({ kind: "missing" }));
return () => {
cancelled = true;
};
// user?.id: progress appears once you sign in.
}, [id, userId, showResult]);
const begin = async (card: QuizCard) => {
if (!(await requireAuth())) return;
try {
const play = await quizApi.start(id);
if (play.done) await showResult(card);
else goTo(card, play);
} catch (err) {
toast.error(
err instanceof QuizError ? err.message : "Could not start the quiz",
);
}
};
const submit = async (card: QuizCard, play: QuizPlay) => {
if (!play.question || submitting || selected.length === 0) return;
setSubmitting(true);
try {
const next = await quizApi.answer(id, play.question.id, selected);
if (next.done) await showResult(card);
else goTo(card, next);
} catch (err) {
// Most likely the question was already answered in another tab: pick up
// wherever the attempt actually is rather than leaving this one stuck.
toast.error(
err instanceof QuizError ? err.message : "Could not save that answer",
);
try {
const resumed = await quizApi.start(id);
if (resumed.done) await showResult(card);
else goTo(card, resumed);
} catch {
/* the toast already said something went wrong */
}
} finally {
setSubmitting(false);
}
};
return (
<main className="mx-auto w-full max-w-2xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
<Link
href="/quizzes"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" /> All quizzes
</Link>
{stage.kind === "loading" && (
<p className="text-sm text-muted-foreground">Loading…</p>
)}
{stage.kind === "missing" && (
<p className="text-sm text-muted-foreground">
This quiz isn&apos;t available.
</p>
)}
{(stage.kind === "intro" ||
stage.kind === "playing" ||
stage.kind === "result") && (
<header className="mb-6">
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground">
{stage.card.title}
</h1>
{stage.card.target_path && (
<Link
href={stage.card.target_path}
className="mt-1 inline-block text-sm text-primary hover:underline"
>
{stage.card.target_title || stage.card.target_path}
</Link>
)}
{/* Shares the quiz itself; the link unfurls with its own OG card. */}
<div className="mt-3">
<CopyPageLinkButton label="Copy quiz link" />
</div>
</header>
)}
{stage.kind === "intro" && (
<section className="space-y-5 rounded-2xl border border-border bg-card p-5">
{stage.card.description && (
<div className="text-sm leading-relaxed text-foreground">
<RichText text={stage.card.description} />
</div>
)}
<p className="text-sm text-muted-foreground">
{stage.card.question_count}{" "}
{stage.card.question_count === 1 ? "question" : "questions"}, one
attempt. Each answer is final once submitted, and your time on each
question is recorded.
</p>
{stage.card.my.state === "in_progress" && (
<p className="text-sm text-muted-foreground">
You&apos;ve answered {stage.card.my.answered} of{" "}
{stage.card.my.total}.
</p>
)}
<button
type="button"
onClick={() => void begin(stage.card)}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 dark:text-white"
>
<ClipboardCheck className="size-4" />
{user
? quizActionLabel(stage.card.my.state)
: "Sign in to take the quiz"}
</button>
</section>
)}
{stage.kind === "playing" && stage.play.question && (
<section className="space-y-5">
<div>
<div className="mb-1.5 flex justify-between text-xs text-muted-foreground">
<span>
Question {stage.play.answered + 1} of {stage.play.total}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${(stage.play.answered / stage.play.total) * 100}%`,
}}
/>
</div>
</div>
<div>
<p className="text-lg leading-relaxed text-foreground">
<RichText text={stage.play.question.prompt} />
</p>
<p className="mt-1 text-xs text-muted-foreground">
{stage.play.question.multi
? "Select all that apply"
: "Select one answer"}
</p>
</div>
<ul
className="space-y-2"
role={stage.play.question.multi ? "group" : "radiogroup"}
>
{stage.play.question.options.map((o) => {
const on = selected.includes(o.id);
const multi = stage.play.question?.multi;
return (
<li key={o.id}>
<button
type="button"
role={multi ? "checkbox" : "radio"}
aria-checked={on}
disabled={submitting}
onClick={() => toggle(stage.play, o.id)}
className={cn(
"flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left text-sm transition-colors",
on
? "border-primary bg-primary/10"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
"disabled:cursor-default",
)}
>
{/* Radio dot for one answer, checkbox for several. */}
<span
aria-hidden
className={cn(
"flex size-4 shrink-0 items-center justify-center border transition-colors",
multi ? "rounded" : "rounded-full",
on
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40",
)}
>
{on &&
(multi ? (
<Check className="size-3" strokeWidth={3} />
) : (
<span className="size-1.5 rounded-full bg-primary-foreground" />
))}
</span>
<span className="min-w-0 flex-1">
<RichText text={o.body} />
</span>
</button>
</li>
);
})}
</ul>
<div className="flex justify-end">
<button
type="button"
disabled={selected.length === 0 || submitting}
onClick={() => void submit(stage.card, stage.play)}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 dark:text-white"
>
{submitting && <Loader2 className="size-4 animate-spin" />}
{stage.play.answered + 1 === stage.play.total
? "Submit and finish"
: "Next question"}
{!submitting && stage.play.answered + 1 < stage.play.total && (
<ArrowRight className="size-4" />
)}
</button>
</div>
</section>
)}
{stage.kind === "result" && <QuizResultView result={stage.result} />}
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { ogForQuizzes } from "@/lib/og";
import { QuizzesList } from "./quizzes-list";
export const metadata: Metadata = {
title: "Quizzes",
description:
"Test your understanding of Śrīla Prabhupāda's books, lectures and conversations.",
...ogForQuizzes(),
};
export default function QuizzesPage() {
// The list reads its tab and category from the URL (useSearchParams),
// which needs a Suspense boundary to render.
return (
<Suspense fallback={null}>
<QuizzesList />
</Suspense>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
/**
* /quizzes — every published quiz, with where you are in each.
*
* Reached from the Quizzes card on the search page. Quizzes are deliberately
* not in search results: they're something to do, not something to look up.
* Signed in only: visitors get the sign-in modal, and home if they dismiss it.
*
* The tab (?tab=todo) and, under All, the category you've drilled into
* (?in=books&book=bg) both live in the URL, so a refresh keeps your place.
*/
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
import { quiz, type QuizCard } from "@/lib/quiz";
import { QuizGrid } from "@/components/quiz/quiz-grid";
import { QuizBrowser } from "@/components/quiz/quiz-browser";
import { cn } from "@/lib/utils";
type Filter = "all" | "todo" | "done";
const FILTERS: readonly Filter[] = ["all", "todo", "done"];
export function QuizzesList() {
const { user } = useSignedInPage();
const userId = user?.id;
const [list, setList] = useState<QuizCard[] | null>(null);
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const tabParam = params.get("tab") as Filter | null;
const filter: Filter =
tabParam && FILTERS.includes(tabParam) ? tabParam : "all";
// Switching tab starts that tab at its top; All is the bare URL.
const setFilter = (f: Filter) =>
router.replace(f === "all" ? pathname : `${pathname}?tab=${f}`, {
scroll: false,
});
useEffect(() => {
if (!userId) return;
let cancelled = false;
quiz
.list()
.then((l) => !cancelled && setList(l))
.catch(() => !cancelled && setList([]));
return () => {
cancelled = true;
};
}, [userId]);
const shown = useMemo(
() =>
(list ?? []).filter((q) =>
filter === "all"
? true
: filter === "done"
? q.my.state === "completed"
: // To do: started but not finished. Untouched quizzes live
// under All.
q.my.state === "in_progress",
),
[list, filter],
);
return (
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-10">
<header className="mb-6">
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground sm:text-3xl">
Quizzes
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Test what you&apos;ve read and heard. Each quiz can be taken once, and
only you can see your results.
</p>
</header>
{user && (
// Phones: three equal, thumb-sized tabs across the width. From sm up
// they shrink back to compact pills.
<div className="mb-4 grid grid-cols-3 gap-1.5 sm:flex sm:gap-1">
{(
[
["all", "All"],
["todo", "To do"],
["done", "Finished"],
] as const
).map(([k, label]) => (
<button
key={k}
type="button"
onClick={() => setFilter(k)}
className={cn(
"min-h-10 rounded-full px-4 py-2 text-[15px] font-medium transition-colors sm:min-h-0 sm:px-3 sm:py-1 sm:text-sm sm:font-normal",
filter === k
? "bg-primary text-primary-foreground dark:text-white"
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground sm:bg-transparent",
)}
>
{label}
</button>
))}
</div>
)}
{!user ? null : list === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : shown.length === 0 ? (
<p className="text-sm text-muted-foreground">
{list.length === 0
? "No quizzes yet — check back soon."
: "Nothing here."}
</p>
) : filter !== "all" ? (
<QuizGrid quizzes={shown} />
) : (
<QuizBrowser
quizzes={shown}
path={{
in: params.get("in"),
book: params.get("book"),
year: params.get("year"),
}}
/>
)}
</main>
);
}
+3 -1
View File
@@ -152,7 +152,9 @@ export default function FeedbacksPage() {
function FeedbackCard({ feedback: f }: { feedback: Feedback }) {
const status = statusOf(f);
const meta = STATUS_META[status];
const category = CATEGORY_LABEL[f.category] ?? f.category;
// The server's label tracks renames in the admin; the map covers an older
// BFF that doesn't send one yet.
const category = f.category_label || CATEGORY_LABEL[f.category] || f.category;
return (
<li className="rounded-xl border border-border bg-card p-4 sm:p-5">
+1
View File
@@ -16,6 +16,7 @@ export default function LegalSettingsPage() {
title="Legal"
>
<NavList>
<NavRow href="/about" label="About Us" />
<NavRow href="/terms" label="Terms of Service" />
<NavRow href="/privacy" label="Privacy Policy" />
<NavRow href="/data-deletion" label="Steps for data deletion" />
+15 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
import { ogDefault } from "@/lib/og";
@@ -8,6 +8,7 @@ import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
import { AuthProvider } from "@/components/auth/auth-provider";
import { AuthGateProvider } from "@/components/auth/auth-gate";
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
import { BroadcastModal } from "@/components/notifications/broadcast-modal";
import { SharePostProvider } from "@/components/social/share-post-provider";
import { Toaster } from "@/components/ui/toaster";
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
@@ -43,6 +44,16 @@ const bricolage = Bricolage_Grotesque({
display: "swap",
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
// Ask the browser to shrink the layout viewport when the software keyboard
// opens, which is the native fix for a bottom-anchored composer. Chrome and
// Android honour it; Safari does not, which is why the chat thread also
// tracks window.visualViewport by hand.
interactiveWidget: "resizes-content",
};
export const metadata: Metadata = {
// Resolves all relative OG/Twitter image URLs and canonical links to
// absolute URLs (required for correct social previews + SEO; Next warns
@@ -105,6 +116,9 @@ export default function RootLayout({
{/* Toaster and FloatingPlayer are global — toasts and
currently-playing audio survive navigation between
route groups. */}
{/* Announcements: at most one modal, only when signed
in, dismissed server-side so it never repeats. */}
<BroadcastModal />
<FloatingPlayer />
<Toaster />
<ServiceWorkerRegistrar />
+15
View File
@@ -60,6 +60,12 @@ type AudioPlayerState = {
type AudioPlayerActions = {
play: (track: AudioTrack) => void;
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
/**
* Gives the track that's already playing a queue to sit in, without
* restarting it — for a queue that arrives after playback began. Ignored
* if the current track isn't in it (the user moved on meanwhile).
*/
attachQueue: (tracks: AudioTrack[]) => void;
pause: () => void;
resume: () => void;
togglePlayPause: () => void;
@@ -469,6 +475,14 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
[saveCurrent],
);
const attachQueue = useCallback((tracks: AudioTrack[]) => {
const current = currentTrackRef.current;
const idx = current ? tracks.findIndex((t) => t.url === current.url) : -1;
if (idx < 0) return;
setPlaylist(tracks);
setCurrentIndex(idx);
}, []);
const pause = useCallback(() => {
audioRef.current?.pause();
saveCurrent();
@@ -630,6 +644,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
currentIndex,
play,
playPlaylist,
attachQueue,
pause,
resume,
togglePlayPause,
@@ -0,0 +1,144 @@
"use client";
/**
* TranscriptQueue — makes the audio player's Next / Previous follow the list
* a recording was found in.
*
* The transcription lists (by place, year, book, type) each wrap their rows
* in this. It does two things with the list, in the order it's displayed:
*
* 1. Play buttons inside it queue the whole list, starting at the row
* pressed — so Next plays the next lecture *in that list*.
* 2. Opening a lecture from it remembers the list (sessionStorage) for that
* lecture's page, so pressing Play there queues the same list.
*
* A lecture reached any other way (search, a shared link) falls back to its
* year in date order — the same order as the Previous / Next links on its
* page and the Year view. Only recordings with audio are ever queued.
*/
import { createContext, useCallback, useContext, useMemo } from "react";
import type { AudioTrack } from "./audio-context";
/** The fields every transcription list row already has. */
export type QueueSource = {
slug: string;
name: string;
type: string;
date: string | null;
place: string | null;
audioUrl: string | null;
};
const STORAGE_KEY = "transcript-queue";
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
/** The track a row plays — identical whether built here or by the button. */
export function transcriptTrack(
t: QueueSource & { audioUrl: string },
): AudioTrack {
return {
url: t.audioUrl,
title: t.name,
subtitle: [t.date, t.place].filter(Boolean).join(" · "),
sourceUrl: `/transcriptions/${t.slug}`,
type: t.type ?? "lecture",
};
}
const QueueContext = createContext<AudioTrack[] | null>(null);
/** The list a play button sits in, or null outside any list. */
export function useTranscriptQueue() {
return useContext(QueueContext);
}
/**
* The list remembered for this lecture's page, if it was opened from one.
* Only the lecture actually clicked qualifies, so an old list never leaks
* onto a page reached some other way.
*/
export function rememberedQueue(slug: string): AudioTrack[] | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const saved = JSON.parse(raw) as { slug: string; tracks: AudioTrack[] };
return saved.slug === slug ? saved.tracks : null;
} catch {
return null;
}
}
/**
* The fallback queue: every recording from that year, in the Year view's
* order (by month and day, undated months last). Null if it can't be loaded.
*/
export async function yearQueue(year: number): Promise<AudioTrack[] | null> {
try {
const res = await fetch(`${BFF_BASE}/api/v1/transcriptions/year/${year}`);
if (!res.ok) return null;
const json = (await res.json()) as {
data?: (Omit<QueueSource, "audioUrl"> & {
month?: number | null;
audio_url?: string | null;
})[];
};
const rows = (json.data ?? []).map((r, i) => ({
...r,
audioUrl: r.audio_url ?? null,
// Known months first, keeping the BFF's date order within them.
sortKey: (r.month && r.month >= 1 && r.month <= 12 ? 0 : 1) * 1e6 + i,
}));
rows.sort((a, b) => a.sortKey - b.sortKey);
return rows
.filter((t): t is typeof t & { audioUrl: string } => !!t.audioUrl)
.map(transcriptTrack);
} catch {
return null;
}
}
export function TranscriptQueue({
items,
children,
}: {
/** Rows in display order. */
items: QueueSource[];
children: React.ReactNode;
}) {
const tracks = useMemo(
() =>
items
.filter((t): t is QueueSource & { audioUrl: string } => !!t.audioUrl)
.map(transcriptTrack),
[items],
);
// Event delegation: the rows are plain <Link>s (some rendered on the
// server), so catch the click on the way down rather than threading a
// handler into every row.
const onClickCapture = useCallback(
(e: React.MouseEvent) => {
const a = (e.target as HTMLElement).closest("a");
const href = a?.getAttribute("href");
if (!href?.startsWith("/transcriptions/")) return;
const slug = href.slice("/transcriptions/".length).split(/[?#]/)[0];
if (!tracks.some((t) => t.sourceUrl === `/transcriptions/${slug}`))
return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ slug, tracks }));
} catch {
/* storage full or blocked — the page just plays the one lecture */
}
},
[tracks],
);
return (
<QueueContext.Provider value={tracks}>
<div className="contents" onClickCapture={onClickCapture}>
{children}
</div>
</QueueContext.Provider>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
/**
* useSignedInPage — for a page that only makes sense signed in.
*
* Signed-out visitors get the sign-in modal over the page; dismissing it
* takes them home. This is the same gate /notifications, /chats and /u/me
* each wire up by hand. Returns the user (null until signed in) so the page
* can hold its content back until then.
*/
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
export function useSignedInPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
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]);
return { user, loading };
}
+30 -47
View File
@@ -1,7 +1,8 @@
"use client";
/**
* Search for people and add them to a group.
* Search for people and add them to a group. Bottom drawer on mobile, modal on
* desktop — see ResponsiveDialog.
*
* 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
@@ -9,12 +10,14 @@
*/
import { useEffect, useState } from "react";
import { Check, Search, X } from "lucide-react";
import { Check, Search } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { chat } from "@/lib/chat";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
type Candidate = { handle: string; name: string; avatar_url: string | null };
@@ -31,6 +34,7 @@ export function AddMembersDialog({
onAdded: () => void;
}) {
const { user } = useAuth();
const isMobile = useMediaQuery("(max-width: 767px)");
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
@@ -68,12 +72,6 @@ export function AddMembersDialog({
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);
@@ -100,35 +98,36 @@ export function AddMembersDialog({
};
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">
<ResponsiveDialog
title="Add members"
description="Search for people to add to this group."
onClose={onClose}
toolbar={
<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
// Not auto-focused on mobile: the keyboard would open mid-way
// through the drawer's slide-up and fight the animation.
autoFocus={!isMobile}
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"
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
}
footer={
<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>
}
>
{!searchable ? (
<p className="p-4 text-sm text-muted-foreground">
Type at least two characters to search.
@@ -150,9 +149,7 @@ export function AddMembersDialog({
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-sm text-foreground">{u.name}</p>
<p className="truncate text-xs text-muted-foreground">
@{u.handle}
</p>
@@ -165,20 +162,6 @@ export function AddMembersDialog({
))}
</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>
</ResponsiveDialog>
);
}
+75 -9
View File
@@ -13,12 +13,16 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Paperclip, SendHorizonal, X } from "lucide-react";
import { Paperclip, Sticker, SendHorizonal, X } from "lucide-react";
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
import {
ComposerAttachments,
type StagedAttachment,
} from "@/components/chat/composer-attachments";
import {
StickerPicker,
type StickerAnchor,
} from "@/components/chat/sticker-picker";
import { compressImage } from "@/lib/compress-image";
import { cn } from "@/lib/utils";
import {
@@ -37,6 +41,7 @@ export function ChatComposer({
replyTo,
onCancelReply,
onSend,
onTyping,
conversationId,
disabled = false,
placeholder = "Message…",
@@ -45,11 +50,15 @@ export function ChatComposer({
conversationId: string;
replyTo: ChatMessage | null;
onCancelReply: () => void;
/** Called on each keystroke; throttled by the caller. */
onTyping?: () => void;
onSend: (
body: string,
replyToId?: string,
previewUrl?: string,
attachments?: UploadedAttachment[],
/** Reward artwork sent on its own. */
stickerKey?: string,
) => void;
disabled?: boolean;
placeholder?: string;
@@ -64,6 +73,9 @@ export function ChatComposer({
const [staged, setStaged] = useState<StagedAttachment[]>([]);
const ref = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
// Non-null opens the sticker picker AND positions it: the popover hangs
// off the button's viewport rect, which only exists at click time.
const [stickerAnchor, setStickerAnchor] = useState<StickerAnchor | null>(null);
const lookupSeq = useRef(0);
// Grow with the content, up to a cap.
@@ -241,14 +253,28 @@ export function ChatComposer({
e.target.value = "";
}}
/>
{/* One field group: attach on the left, sticker on the right, the
text between them. The border and background live here rather than
on the textarea so all three read as a single control, and
focus-within moves the ring to the group. */}
<div
className={cn(
// Minimal horizontal padding: the icon buttons keep a full-height
// tap target, but sit close to the group's edges and to the text
// rather than floating in their own gutters.
"flex flex-1 items-end gap-0.5 rounded-2xl border border-border bg-muted/40 px-1 py-1",
"focus-within:ring-1 focus-within:ring-primary/40",
disabled && "opacity-60",
)}
>
<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"
className="inline-flex h-8 w-8 mr-1 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Paperclip className="size-4" />
<Paperclip className="size-5" />
</button>
<textarea
@@ -257,11 +283,18 @@ export function ChatComposer({
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => {
setValue(e.target.value);
// Fired on every keystroke; the handler throttles itself, so the
// thread hears "still typing" every few seconds rather than per
// character.
onTyping?.();
}}
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.
// 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" &&
@@ -271,11 +304,33 @@ export function ChatComposer({
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",
// Bare: the group above owns the frame. Only the type size and
// the growth behaviour belong to the field itself.
"min-w-0 flex-1 resize-none bg-transparent py-1.5",
// 16px on touch devices: iOS Safari zooms the whole page when a
// focused field computes below that, which on a composer means
// the thread jumps every time the keyboard opens. Pointer-based
// devices keep the app's 14px chrome.
"text-sm pointer-coarse:text-base",
"placeholder:text-muted-foreground focus:outline-none",
"disabled:opacity-60",
)}
/>
<button
type="button"
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
// Top-centre of the button: the popover opens upward from here.
setStickerAnchor({ x: r.left + r.width / 2, y: r.top });
}}
disabled={disabled}
aria-label="Send a sticker"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Sticker className="size-5" />
</button>
</div>
<button
type="button"
onClick={submit}
@@ -289,6 +344,17 @@ export function ChatComposer({
<SendHorizonal className="size-4" />
</button>
</div>
{stickerAnchor && (
<StickerPicker
anchor={stickerAnchor}
// Sent on its own, with no text: a sticker IS the message.
onPick={(sticker) =>
onSend("", undefined, undefined, undefined, sticker.key)
}
onClose={() => setStickerAnchor(null)}
/>
)}
</div>
);
}
+15 -4
View File
@@ -15,6 +15,7 @@ import { useState } from "react";
import { BellOff, Bell, ShieldBan, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { relationships } from "@/lib/social";
import {
chat,
@@ -44,6 +45,9 @@ export function ChatProfileHeader({
onChanged: () => void;
}) {
const [busy, setBusy] = useState(false);
// Blocking asks first; unblocking is immediate, since it only restores what
// was there before.
const [confirmBlock, setConfirmBlock] = useState(false);
const rel = detail.relationship;
const peer = detail.peer;
const muted = isMuted(detail);
@@ -64,9 +68,6 @@ export function ChatProfileHeader({
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);
@@ -134,7 +135,9 @@ export function ChatProfileHeader({
variant={rel?.blocked ? "outline" : "destructive"}
size="sm"
disabled={busy}
onClick={toggleBlock}
onClick={() =>
rel?.blocked ? void toggleBlock() : setConfirmBlock(true)
}
>
{rel?.blocked ? (
<>
@@ -148,6 +151,14 @@ export function ChatProfileHeader({
</Button>
)}
</div>
<ConfirmModal
open={confirmBlock}
onClose={() => setConfirmBlock(false)}
title={`Block ${peer?.name ?? "this person"}?`}
message="They won't be able to message you, and you won't see their messages. You can unblock them here later."
confirmLabel="Block"
onConfirm={toggleBlock}
/>
</div>
);
}
+21 -8
View File
@@ -14,6 +14,7 @@ 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 { ConfirmModal } from "@/components/ui/confirm-modal";
import { chat, isGroupAdmin, type ConversationDetail } from "@/lib/chat";
const ROLE_LABEL: Record<string, string> = {
@@ -34,6 +35,9 @@ export function GroupMembersTab({
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
// The member being removed. Non-null asks first.
const [confirmRemove, setConfirmRemove] =
useState<ConversationDetail["participants"][number] | null>(null);
const [adding, setAdding] = useState(false);
const iAmAdmin = isGroupAdmin(detail.my_role);
const iAmOwner = detail.my_role === "owner";
@@ -132,14 +136,8 @@ export function GroupMembersTab({
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`,
);
}}
// Ask first — the modal calls act() on confirm.
onClick={() => setConfirmRemove(m)}
className="shrink-0 rounded-md p-1.5 text-destructive hover:opacity-80 disabled:opacity-40"
>
<UserMinus className="size-4" />
@@ -158,6 +156,21 @@ export function GroupMembersTab({
onAdded={onChanged}
/>
)}
<ConfirmModal
open={confirmRemove !== null}
onClose={() => setConfirmRemove(null)}
title={`Remove ${confirmRemove?.name ?? "this member"}?`}
message="They'll lose access to the conversation and its history from here on."
confirmLabel="Remove"
onConfirm={async () => {
if (!confirmRemove) return;
await act(
confirmRemove.handle,
() => chat.removeMember(conversationId, confirmRemove.handle),
`${confirmRemove.name} was removed`,
);
}}
/>
</div>
);
}
+16 -11
View File
@@ -13,6 +13,7 @@ import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/ui/user-avatar";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { compressImage } from "@/lib/compress-image";
import {
chat,
@@ -38,6 +39,7 @@ export function GroupSettingsTab({
const router = useRouter();
const [title, setTitle] = useState(detail.title ?? "");
const [busy, setBusy] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const iAmAdmin = isGroupAdmin(detail.my_role);
@@ -96,15 +98,6 @@ export function GroupSettingsTab({
};
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);
@@ -175,7 +168,7 @@ export function GroupSettingsTab({
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"
className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
/>
<Button size="sm" disabled={!renamed || busy} onClick={rename}>
Save
@@ -219,7 +212,7 @@ export function GroupSettingsTab({
variant="destructive"
size="sm"
disabled={busy}
onClick={leave}
onClick={() => setConfirmLeave(true)}
>
<LogOut className="size-4" /> Leave group
</Button>
@@ -230,6 +223,18 @@ export function GroupSettingsTab({
</p>
)}
</section>
<ConfirmModal
open={confirmLeave}
onClose={() => setConfirmLeave(false)}
title="Leave this group?"
message={
detail.my_role === "owner"
? "You own this group. The longest-serving admin becomes the owner in your place."
: "You'll stop receiving messages from this conversation."
}
confirmLabel="Leave"
onConfirm={leave}
/>
</div>
);
}
+5 -2
View File
@@ -37,7 +37,10 @@ export function MediaGrid({
onClick={() => onOpen(0)}
className="block overflow-hidden rounded-lg"
>
<Tile item={items[0]} className="max-h-72 w-auto" />
<Tile
item={items[0]}
className="max-h-64 w-auto sm:max-h-52 sm:max-w-52"
/>
</button>
);
}
@@ -46,7 +49,7 @@ export function MediaGrid({
// 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">
<div className="grid w-56 grid-cols-2 gap-0.5 overflow-hidden rounded-lg sm:w-52">
{visible.map((item, i) => {
const isLast = i === visible.length - 1;
return (
+23 -3
View File
@@ -7,9 +7,16 @@
* 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.
*
* Portalled to <body>, and that is load-bearing rather than tidiness: every
* bubble sits inside MessageGestures, which sets a transform for swipe-to-reply
* — and a transformed ancestor becomes the containing block for `position:
* fixed`. Rendered in place, `inset-0` would resolve against the bubble, and
* the message list's overflow would then clip it.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, Download, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { attachmentUrl, type Attachment } from "@/lib/chat";
@@ -53,6 +60,16 @@ export function MediaLightbox({
return () => window.removeEventListener("keydown", onKey);
}, [index, items.length, onClose, scrollTo]);
// A full-screen viewer over a scrollable thread: without this the thread
// scrolls behind the image once the carousel reaches its own edge.
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
const current = items[index];
/**
@@ -85,8 +102,10 @@ export function MediaLightbox({
}
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-black/95">
if (typeof document === "undefined") return null;
return createPortal(
<div className="fixed inset-0 z-[100] flex flex-col bg-black/95">
<div className="flex items-center justify-between gap-2 px-3 py-2 text-white">
<button
type="button"
@@ -167,7 +186,8 @@ export function MediaLightbox({
</>
)}
</div>
</div>
</div>,
document.body,
);
}
+17 -1
View File
@@ -17,6 +17,9 @@ 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 { QuoteCard } from "@/components/chat/quote-card";
import { cn } from "@/lib/utils";
import { SharedPostCard } from "@/components/chat/shared-post-card";
import { attachmentUrl, type Attachment } from "@/lib/chat";
function humanSize(bytes?: number | null): string {
@@ -50,13 +53,26 @@ export function MessageAttachments({
return (
<>
<div className="mt-1 flex flex-col gap-1.5">
<div
className={cn(
"flex flex-col gap-1.5",
// Separates attachments from body text above them. A shared post
// leads its bubble, so there is nothing to separate from.
attachments.some((a) => a.kind === "entity") ? undefined : "mt-1",
)}
>
{media.length > 0 && (
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
)}
{rest.map((a) => {
switch (a.kind) {
case "entity":
return <SharedPostCard key={a.id} attachment={a} />;
case "quote":
return <QuoteCard key={a.id} attachment={a} />;
case "link":
return (
<LinkPreviewCard
+34 -3
View File
@@ -40,16 +40,23 @@ const CLAIM_PX = 10;
const LONG_PRESS_MS = 450;
/** Movement that cancels a long press — a drag is not a hold. */
const LONG_PRESS_SLOP_PX = 8;
/** Longest gap between two taps still read as a double tap. */
const DOUBLE_TAP_MS = 300;
/** How far the second tap may land from the first. */
const DOUBLE_TAP_SLOP_PX = 24;
export function MessageGestures({
onReply,
onMenu,
onDoubleTap,
disabled = false,
children,
}: {
onReply: () => void;
/** Opens the context menu at the given viewport point. */
onMenu: (point: { x: number; y: number }) => void;
/** Two quick taps in the same spot — sends a heart. */
onDoubleTap?: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
@@ -61,6 +68,8 @@ export function MessageGestures({
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);
// When and where the last tap landed, for pairing it with the next one.
const lastTap = useRef<{ t: number; x: number; y: number } | null>(null);
const cancelHold = useCallback(() => {
if (holdTimer.current) {
@@ -124,10 +133,29 @@ export function MessageGestures({
setOffset(Math.max(0, Math.min(travel, MAX_PX)));
};
const onPointerUp = () => {
const onPointerUp = (e: React.PointerEvent) => {
cancelHold();
// A long press already acted; releasing must not also fire a reply.
if (!consumed.current && claimed.current && offset >= TRIGGER_PX) onReply();
if (!consumed.current && claimed.current && offset >= TRIGGER_PX) {
onReply();
lastTap.current = null;
} else if (!consumed.current && !claimed.current && onDoubleTap) {
// A stationary tap. Paired with a recent nearby one, it is a double tap.
// Tracked here rather than with onDoubleClick so touch and mouse behave
// the same, and so a swipe or a long press can never count as a tap.
const now = Date.now();
const prev = lastTap.current;
const near =
prev !== null &&
Math.abs(e.clientX - prev.x) < DOUBLE_TAP_SLOP_PX &&
Math.abs(e.clientY - prev.y) < DOUBLE_TAP_SLOP_PX;
if (prev && near && now - prev.t < DOUBLE_TAP_MS) {
lastTap.current = null;
onDoubleTap();
} else {
lastTap.current = { t: now, x: e.clientX, y: e.clientY };
}
}
reset();
};
@@ -135,7 +163,10 @@ export function MessageGestures({
return (
<div
className="relative"
// Full width so the whole row is draggable — WhatsApp lets you start the
// gesture anywhere on the line, including the empty space opposite the
// bubble. The bubble caps its own width inside this.
className="relative w-full select-none [-webkit-touch-callout:none]"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
+217 -31
View File
@@ -13,8 +13,16 @@ 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 { HEART_EMOJI } from "@/components/chat/reaction-row";
import { StickerGrid } from "@/components/chat/sticker-grid";
import { cn } from "@/lib/utils";
import type { ChatMessage, ChatPeer, Receipts, ReplyPreview } from "@/lib/chat";
import type {
Attachment,
ChatMessage,
ChatPeer,
Receipts,
ReplyPreview,
} from "@/lib/chat";
/** Time only — the day is already established by the date separator above. */
function clockTime(iso: string): string {
@@ -24,6 +32,52 @@ function clockTime(iso: string): string {
});
}
/**
* True when a message carries a shared post, which renders as a standalone
* card rather than bubble content.
*/
function sharesAnEntity(m: ChatMessage): boolean {
return (m.attachments ?? []).some((a) => a.kind === "entity");
}
/** Stickers on a message, if any. */
function stickersOn(m: ChatMessage): Attachment[] {
return (m.attachments ?? []).filter((a) => a.kind === "sticker");
}
/**
* True when a message is nothing but stickers. Those render bare — artwork
* inside a coloured bubble reads as a pasted image rather than a sticker.
*/
function isStickerOnly(m: ChatMessage): boolean {
const atts = m.attachments ?? [];
return !m.body && atts.length > 0 && atts.every((a) => a.kind === "sticker");
}
/** True when a message carries photos or video. */
function carriesMedia(m: ChatMessage): boolean {
return (m.attachments ?? []).some(
(a) => a.kind === "image" || a.kind === "video",
);
}
/**
* True when a message's content brings its own frame — a shared post card,
* stickers, or media. All three are images with their own edges, and a
* coloured bubble around them reads as a second frame.
*/
function rendersBare(m: ChatMessage): boolean {
return sharesAnEntity(m) || isStickerOnly(m) || carriesMedia(m);
}
/** Distinct emoji shown before the rest collapse into "+N". */
const MAX_TRAIL_EMOJI = 4;
/** People who reacted, across all emoji. */
function totalReactions(m: ChatMessage): number {
return (m.reactions ?? []).reduce((n, r) => n + r.count, 0);
}
function bubbleCorners(mine: boolean, index: number, total: number): string {
if (total === 1) return "rounded-2xl";
const first = index === 0;
@@ -45,12 +99,10 @@ function bubbleCorners(mine: boolean, index: number, total: number): string {
*/
function QuotedReply({
reply,
mine,
selfId,
nameFor,
}: {
reply: ReplyPreview;
mine: boolean;
selfId: string;
nameFor: (senderId: string | null | undefined) => string;
}) {
@@ -63,12 +115,11 @@ function QuotedReply({
: 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",
)}
// Neutral on both sides: this now sits on the page background rather
// than inside a bubble, so the old primary-foreground treatment (white
// text, for a primary bubble) would be invisible in light mode.
// Spacing comes from the column's gap — no margin of its own.
className="w-full rounded-lg border-l-2 border-primary/40 bg-muted/60 px-2 py-1 text-xs text-muted-foreground"
>
<span className="block font-medium">{who}</span>
<span className="line-clamp-1 break-words">
@@ -81,7 +132,7 @@ function QuotedReply({
export function MessageGroup({
messages,
mine,
peer,
sender,
receipts,
showAvatar,
selfId,
@@ -90,10 +141,14 @@ export function MessageGroup({
starredIds,
onRetry,
nameFor,
onReact,
onTrailClick,
}: {
messages: ChatMessage[];
mine: boolean;
peer?: ChatPeer | null;
/** Who sent this group of messages. Undefined for Kautilya, system
* messages, or a member who has since left. */
sender?: ChatPeer;
receipts: Receipts | null;
/** Groups show an avatar; a DM's own side never does. */
showAvatar: boolean;
@@ -107,6 +162,10 @@ export function MessageGroup({
onRetry: (message: ChatMessage) => void;
/** Names a quoted sender; supplied by the thread's participant list. */
nameFor: (senderId: string | null | undefined) => string;
/** Set or toggle a reaction. Picking an existing one removes it. */
onReact: (message: ChatMessage, emoji: string) => void;
/** Tapping the trail: retract if you reacted, otherwise show who did. */
onTrailClick: (message: ChatMessage) => void;
}) {
const last = messages[messages.length - 1];
@@ -119,20 +178,31 @@ export function MessageGroup({
>
{!mine && showAvatar && (
<UserAvatar
src={peer?.avatar_url}
name={peer?.name ?? "?"}
src={sender?.avatar_url}
name={sender?.name ?? "?"}
className="size-7 shrink-0"
textClassName="text-[10px]"
/>
)}
<div
// flex-1 rather than max-w: the column spans the row so a swipe can
// start anywhere on it. min-w-0 lets long words wrap instead of
// widening the row.
className={cn(
"flex max-w-[78%] flex-col gap-0.5",
"flex min-w-0 flex-1 flex-col gap-0.5",
mine ? "items-end" : "items-start",
!mine && !showAvatar && "ml-9",
)}
>
{/* Name the sender once per run of messages. An avatar alone is thin
identification in a group — two people with similar photos, or
none at all, are indistinguishable at 28px. */}
{!mine && showAvatar && (
<span className="px-1 text-[11px] font-medium text-muted-foreground">
{sender?.name ?? "Unknown"}
</span>
)}
{messages.map((m, i) => (
// A deleted message has nothing to quote, so it isn't swipeable.
<MessageGestures
@@ -140,37 +210,101 @@ export function MessageGroup({
disabled={!!m.deleted_at || !!m.pending}
onReply={() => onReply(m)}
onMenu={(point) => onMenu(m, point)}
onDoubleTap={() => onReact(m, HEART_EMOJI)}
>
{m.reply_to && !m.deleted_at && (
<div
className={cn(
"flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
{/* Outside the bubble, above it: the quote is context for the
reply, not part of what was said. Capped to the bubble's
own width so the two read as one stack. */}
<div className="max-w-[78%]">
<QuotedReply
reply={m.reply_to}
selfId={selfId}
nameFor={nameFor}
/>
</div>
</div>
)}
<div
className={cn(
"flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"group/bubble relative px-3 py-2 text-sm",
"group/bubble relative max-w-[78%] text-sm",
bubbleCorners(mine, i, messages.length),
// A shared post is a card in its own right: it brings its
// own border, rounding and background, so the bubble drops
// its chrome rather than drawing a second frame around it.
// Any caption gets the padding back on its own line below.
rendersBare(m)
? "overflow-hidden bg-transparent"
: cn(
"px-3 py-2",
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} />}
{/* A shared post leads and its caption follows, the way
the post itself reads. Everything else keeps text
first, with attachments beneath it. */}
{isStickerOnly(m) ? (
<StickerGrid
conversationId={m.conversation_id}
messageId={m.id}
stickers={stickersOn(m)}
mine={mine}
/>
) : (
sharesAnEntity(m) && (
<MessageAttachments attachments={m.attachments} />
)
)}
{m.body && (
<span
className={cn(
"block",
// The bubble gave up its padding and colour, so a
// caption carries its own — otherwise it would sit
// as bare text on the thread background.
rendersBare(m) &&
cn(
"mt-0.5 rounded-lg px-3 py-2",
mine
? "bg-primary text-primary-foreground dark:text-white"
: "bg-muted text-foreground",
),
)}
>
<MessageText body={m.body} entities={m.entities} />
</span>
)}
{!sharesAnEntity(m) && !isStickerOnly(m) && (
<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>
{m.pending && !m.body && !m.attachments?.length && (
<span className="italic opacity-80">
Sending attachment…
</span>
)}
{starredIds.has(m.id) && (
<Star
@@ -200,6 +334,57 @@ export function MessageGroup({
</button>
)}
</div>
</div>
{m.reactions?.length > 0 && (
// Aligned like the bubble it belongs to. The gesture wrapper is
// full-width, so without this the trail sits at the left edge
// while your own bubble is over on the right.
<div
className={cn(
"flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
<button
type="button"
// ONE control, not one per emoji. Two different jobs depending
// on whether you are in the reaction list already: if you have
// reacted, this retracts it; if you have not, it opens the
// list of who reacted. Per-chip toggling would make "see who
// reacted" unreachable for anyone who had.
onClick={() => onTrailClick(m)}
aria-label={
m.reactions.some((r) => r.mine)
? "Remove your reaction"
: "See who reacted"
}
className={cn(
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs",
mine ? "mr-1" : "ml-1",
m.reactions.some((r) => r.mine)
? "border-primary/40 bg-primary/10"
: "border-border",
)}
>
{m.reactions.slice(0, MAX_TRAIL_EMOJI).map((r) => (
<span key={r.emoji}>{r.emoji}</span>
))}
{m.reactions.length > MAX_TRAIL_EMOJI && (
<span className="tabular-nums text-muted-foreground">
+{m.reactions.length - MAX_TRAIL_EMOJI}
</span>
)}
{/* Total people, not distinct emoji — "😂❤️ 5" reads as five
reactions, which is what it is. */}
{totalReactions(m) > 1 && (
<span className="ml-0.5 tabular-nums text-muted-foreground">
{totalReactions(m)}
</span>
)}
</button>
</div>
)}
</MessageGestures>
))}
@@ -223,8 +408,10 @@ export function MessageGroup({
Not sent — retry
</button>
)}
{mine && !last.deleted_at && !last.failed && (
last.pending ? (
{mine &&
!last.deleted_at &&
!last.failed &&
(last.pending ? (
<Clock
role="img"
aria-label="Sending"
@@ -232,8 +419,7 @@ export function MessageGroup({
/>
) : (
<MessageTicks state={tickState(last.seq, receipts)} />
)
)}
))}
</div>
</div>
</div>
+31 -4
View File
@@ -44,7 +44,7 @@ function dayLabel(iso: string): string {
export function MessageList({
messages,
selfId,
peer,
senderFor,
receipts,
isGroup,
hasMore,
@@ -55,11 +55,15 @@ export function MessageList({
starredIds,
onRetry,
nameFor,
typingNames,
onReact,
onTrailClick,
}: {
/** Ascending by seq — oldest first. */
messages: ChatMessage[];
selfId: string;
peer?: ChatPeer | null;
/** Resolves a message's sender for their avatar and name. */
senderFor: (senderId: string | null | undefined) => ChatPeer | undefined;
receipts: Receipts | null;
isGroup: boolean;
hasMore: boolean;
@@ -70,6 +74,11 @@ export function MessageList({
starredIds: Set<string>;
onRetry: (message: ChatMessage) => void;
nameFor: (senderId: string | null | undefined) => string;
/** Who is composing right now. Empty when nobody is. */
typingNames: string[];
onReact: (message: ChatMessage, emoji: string) => void;
/** Tapping the reaction trail. */
onTrailClick: (message: ChatMessage) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomRef = useRef(true);
@@ -133,7 +142,10 @@ export function MessageList({
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4"
// overflow-x-hidden is load-bearing: `overflow-y: auto` computes the
// other axis to `auto` rather than `visible`, so the swipe-to-reply
// transform would otherwise give the thread a horizontal scrollbar.
className="flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-4"
>
{loadingMore && (
<p className="pb-3 text-center text-xs text-muted-foreground">
@@ -160,7 +172,7 @@ export function MessageList({
key={row.key}
messages={row.messages}
mine={row.senderId === selfId}
peer={peer}
sender={senderFor(row.senderId)}
receipts={receipts}
showAvatar={isGroup}
selfId={selfId}
@@ -169,10 +181,25 @@ export function MessageList({
starredIds={starredIds}
onRetry={onRetry}
nameFor={nameFor}
onReact={onReact}
onTrailClick={onTrailClick}
/>
),
)}
</div>
{typingNames.length > 0 && (
<p
aria-live="polite"
className="px-1 pt-2 text-xs italic text-muted-foreground"
>
{typingNames.length === 1
? `${typingNames[0]} is typing…`
: `${typingNames.slice(0, 2).join(", ")}${
typingNames.length > 2 ? " and others" : ""
} are typing…`}
</p>
)}
</div>
);
}
+19 -1
View File
@@ -18,6 +18,7 @@ import {
StarOff,
Trash2,
} from "lucide-react";
import { ReactionRow } from "@/components/chat/reaction-row";
import { cn } from "@/lib/utils";
export type MenuPoint = { x: number; y: number };
@@ -38,11 +39,17 @@ const MARGIN = 8;
export function MessageMenu({
point,
starred,
myReaction,
onReact,
actions,
onClose,
}: {
point: MenuPoint;
starred: boolean;
/** The viewer's current emoji on this message, if any. */
myReaction?: string;
/** Picking the same emoji again is a toggle — the caller decides. */
onReact: (emoji: string) => void;
actions: MessageMenuActions;
onClose: () => void;
}) {
@@ -89,8 +96,19 @@ export function MessageMenu({
// 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"
className="fixed z-50 min-w-56 select-none overflow-hidden rounded-xl border border-border bg-background pb-1 shadow-lg"
>
{/* Reactions lead: they are the most common thing to want from a
long press, and putting them in this menu keeps one gesture
responsible for one surface. */}
<ReactionRow
current={myReaction}
onPick={(emoji) => {
onReact(emoji);
onClose();
}}
/>
<Item icon={<Reply className="size-4" />} onClick={run(actions.onReply)}>
Reply
</Item>
+28 -41
View File
@@ -1,7 +1,8 @@
"use client";
/**
* Create a group: name it, pick people, done.
* Create a group: name it, pick people, done. Bottom drawer on mobile, modal on
* desktop — see ResponsiveDialog.
*
* Members are named by handle, matching the member-management API — search
* results already identify people that way, so nothing needs id plumbing.
@@ -12,15 +13,18 @@ import { useRouter } from "next/navigation";
import { Check, Search, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { chat } from "@/lib/chat";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
type Candidate = { handle: string; name: string; avatar_url: string | null };
export function NewGroupDialog({ onClose }: { onClose: () => void }) {
const router = useRouter();
const { user } = useAuth();
const isMobile = useMediaQuery("(max-width: 767px)");
const [title, setTitle] = useState("");
const [query, setQuery] = useState("");
// Results are stored with the query they belong to, so what's displayed can
@@ -56,12 +60,6 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
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);
@@ -85,29 +83,21 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
};
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">
<ResponsiveDialog
title="New group"
description="Name the group and choose who to include."
onClose={onClose}
toolbar={
<div className="space-y-3">
<input
autoFocus
// Not auto-focused on mobile: the keyboard would open mid-way
// through the drawer's slide-up and fight the animation.
autoFocus={!isMobile}
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"
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base 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" />
@@ -115,7 +105,7 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Add people"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
/>
</div>
@@ -135,8 +125,17 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
}
footer={
<Button
className="w-full"
disabled={title.trim() === "" || busy}
onClick={create}
>
Create group
</Button>
}
>
{!searchable ? (
<p className="p-4 text-sm text-muted-foreground">
Search to add people. You can also add them later.
@@ -171,18 +170,6 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
))}
</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>
</ResponsiveDialog>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
/**
* A passage shared from the library, rendered as a quotation.
*
* Tapping it returns the reader to the exact phrase: the stored path carries
* the `?hl=` fragment that TextHighlighter resolves on arrival, scrolling to
* the passage and flashing it.
*
* The path is always app-relative — the BFF rejects anything else — so this
* can navigate with the router rather than leaving the app.
*
* Styling is deliberately bare. This sits INSIDE a chat bubble, which already
* supplies the padding, the background and the rounding; giving the quote its
* own would draw a second bubble within the first. A left rule and italics are
* enough to say "quotation", and every colour is inherited so the same markup
* reads correctly on your own primary-coloured bubble and on the muted one
* opposite.
*/
import { useRouter } from "next/navigation";
import type { Attachment } from "@/lib/chat";
export function QuoteCard({ attachment }: { attachment: Attachment }) {
const router = useRouter();
const path = attachment.url ?? "";
const passage = attachment.description ?? "";
return (
<button
type="button"
onClick={() => path && router.push(path)}
disabled={!path}
className="block w-full border-l-2 border-current/30 pl-2.5 text-left transition-opacity hover:opacity-80 disabled:cursor-default disabled:hover:opacity-100"
>
{/* The passage leads; the citation follows in the quieter role a
reference normally takes. */}
<span className="block text-sm italic leading-snug">{passage}</span>
{attachment.title && (
<span className="mt-0.5 block truncate text-xs opacity-70">
{attachment.title}
</span>
)}
</button>
);
}
+202
View File
@@ -0,0 +1,202 @@
"use client";
/**
* The emoji row at the top of a message's long-press menu.
*
* Five quick reactions plus a "+" that opens the full set — a drawer on
* mobile, a popover anchored to the "+" on desktop. A modal would take over
* the screen and hide the message being reacted to, which is the one thing
* that needs to stay visible while choosing.
*
* It sits inside the context menu rather than being its own popover: long
* press is one gesture, and two surfaces competing for it would mean neither
* felt reliable.
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { EMOJI_GROUPS } from "@/components/ui/emoji-picker";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
const POPOVER_W = 320;
const POPOVER_H = 320;
const EDGE_PAD = 8;
/** What a double-tap sends. Matches the server's own default. */
export const HEART_EMOJI = "❤️";
/** The five offered without opening the full set. */
export const QUICK_REACTIONS = [HEART_EMOJI, "😂", "🙏", "😮", "😢"];
export function ReactionRow({
current,
onPick,
}: {
/** The viewer's existing reaction, so it can be shown as selected. */
current?: string;
/** Picking the current emoji again clears it — the caller decides. */
onPick: (emoji: string) => void;
}) {
// Non-null both opens the full set and positions it: the popover hangs off
// the "+" button's rect, which only exists at click time.
const [browsing, setBrowsing] = useState<{ x: number; y: number } | null>(
null,
);
const close = () => setBrowsing(null);
return (
<>
<div className="flex items-center gap-1 border-b border-border px-2 py-2">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => onPick(emoji)}
aria-label={`React ${emoji}`}
aria-pressed={current === emoji}
className={cn(
"inline-flex size-9 items-center justify-center rounded-full text-lg transition-colors",
current === emoji ? "bg-primary/15" : "hover:bg-muted",
)}
>
{emoji}
</button>
))}
<button
type="button"
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
// Top-centre of the "+": the popover opens upward from here.
setBrowsing({ x: r.left + r.width / 2, y: r.top });
}}
aria-label="More reactions"
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Plus className="size-4" />
</button>
</div>
{browsing && <FullSet anchor={browsing} onPick={onPick} onClose={close} />}
</>
);
}
/** The whole emoji set: popover on desktop, drawer on touch. */
function FullSet({
anchor,
onPick,
onClose,
}: {
anchor: { x: number; y: number };
onPick: (emoji: string) => void;
onClose: () => void;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
// Desktop only: Escape closes. The drawer handles its own dismissal, and a
// popover deliberately doesn't lock scrolling behind it.
useEffect(() => {
if (isMobile) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isMobile, onClose]);
const grid = (
<div className="space-y-4 p-3">
{EMOJI_GROUPS.map((group) => (
<section key={group.label}>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
<div className="grid grid-cols-8 gap-1">
{group.emojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onClose();
onPick(emoji);
}}
aria-label={`React ${emoji}`}
className="inline-flex size-9 items-center justify-center rounded-md text-lg hover:bg-muted"
>
{emoji}
</button>
))}
</div>
</section>
))}
</div>
);
if (isMobile) {
return (
<Drawer open onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left">
<DrawerTitle>Pick a reaction</DrawerTitle>
<DrawerDescription>
Choose any emoji to react with.
</DrawerDescription>
</DrawerHeader>
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
{grid}
</div>
</DrawerContent>
</Drawer>
);
}
if (typeof document === "undefined") return null;
// Opens upward: the "+" sits in a menu raised over a message, so there is
// usually more room above it than below. Flips down only if that would clip.
let left = anchor.x - POPOVER_W / 2;
if (left + POPOVER_W > window.innerWidth - EDGE_PAD) {
left = window.innerWidth - POPOVER_W - EDGE_PAD;
}
if (left < EDGE_PAD) left = EDGE_PAD;
// Room above the button, less a small gap and the viewport edge.
const roomAbove = anchor.y - 8 - EDGE_PAD;
// Open upward when there is any reasonable room, which by the composer
// there almost always is.
const openUp = roomAbove > 160;
// Anchored by whichever edge faces the button, so the panel hugs it
// regardless of how tall its contents actually are. Positioning by top
// minus a MAX height floats it away whenever the content is shorter.
const vertical = openUp
? { bottom: window.innerHeight - anchor.y + 8 }
: { top: anchor.y + 8 };
// Never taller than the space it has.
const maxH = Math.min(
POPOVER_H,
openUp ? roomAbove : window.innerHeight - anchor.y - 8 - EDGE_PAD,
);
return createPortal(
<>
{/* Catches the outside click. Transparent — a popover dims nothing. */}
<div className="fixed inset-0 z-[60]" onClick={onClose} aria-hidden />
<div
role="dialog"
aria-label="Pick a reaction"
style={{ left, ...vertical, width: POPOVER_W, maxHeight: maxH }}
className="fixed z-[61] flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg"
>
<div className="flex-1 overflow-y-auto">{grid}</div>
</div>
</>,
document.body,
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
/**
* Who reacted to a message, and with what.
*
* Fetched on open rather than shipped with every message: the thread renders
* from grouped counts, and sending the full membership of every reaction on
* every page would grow with the room.
*/
import { useEffect, useState } from "react";
import { UserAvatar } from "@/components/ui/user-avatar";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
import { chat, type ReactionDetail } from "@/lib/chat";
export function ReactionsDrawer({
conversationId,
messageId,
onClose,
}: {
conversationId: string;
messageId: string;
onClose: () => void;
}) {
const [rows, setRows] = useState<ReactionDetail[] | null>(null);
useEffect(() => {
let cancelled = false;
chat
.reactionDetails(conversationId, messageId)
.then((r) => {
if (!cancelled) setRows(r);
})
.catch(() => {
if (!cancelled) setRows([]);
});
return () => {
cancelled = true;
};
}, [conversationId, messageId]);
return (
<ResponsiveDialog
title="Reactions"
description={
rows === null
? "Loading…"
: `${rows.length} ${rows.length === 1 ? "person" : "people"} reacted.`
}
onClose={onClose}
>
{rows === null ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : rows.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">No reactions.</p>
) : (
<ul className="divide-y divide-border">
{rows.map((r) => (
<li
key={r.user.id}
className="flex items-center gap-3 px-4 py-2.5"
>
<UserAvatar
src={r.user.avatar_url}
name={r.user.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{r.user.name}</p>
<p className="truncate text-xs text-muted-foreground">
@{r.user.handle}
</p>
</div>
<span className="shrink-0 text-lg" aria-label={r.emoji}>
{r.emoji}
</span>
</li>
))}
</ul>
)}
</ResponsiveDialog>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
/**
* The shell the chat dialogs sit in: a bottom drawer on mobile, a centred
* modal on desktop.
*
* Extracted because "Add members" and "New group" are the same shape — title,
* a fixed toolbar of inputs, a scrolling list of people, a confirm button —
* and each would otherwise carry its own copy of the drawer/modal split.
*
* Follows the pattern already used by ReportDialog and ShareModal: the same
* `useMediaQuery("(max-width: 767px)")` breakpoint, vaul's Drawer below it,
* and a portalled modal above.
*/
import type { ReactNode } from "react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
export function ResponsiveDialog({
title,
description,
onClose,
toolbar,
footer,
children,
}: {
title: string;
description: string;
onClose: () => void;
/** Fixed block under the title — search fields, selected chips. */
toolbar?: ReactNode;
/** Pinned action row at the bottom. */
footer?: ReactNode;
/** The scrolling region between toolbar and footer. */
children: ReactNode;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
// Desktop only: Escape to dismiss, and lock the page behind the modal. The
// drawer does both itself, and doubling up on the scroll lock fights vaul's
// own body handling on iOS.
useEffect(() => {
if (isMobile) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [isMobile, onClose]);
if (isMobile) {
return (
<Drawer open onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
{toolbar && <div className="border-b border-border px-4 pb-3">{toolbar}</div>}
{/* Capped rather than flex-1: a drawer sizes to its content, so an
unbounded list would grow the sheet past the screen. */}
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
{children}
</div>
{footer && <div className="border-t border-border p-4">{footer}</div>}
</DrawerContent>
</Drawer>
);
}
if (typeof document === "undefined") return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={onClose}
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="flex max-h-[80vh] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-xl">
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h2 className="font-[family-name:var(--font-title)] text-lg font-semibold text-foreground">
{title}
</h2>
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
{toolbar && <div className="border-b border-border p-3">{toolbar}</div>}
<div className="flex-1 overflow-y-auto">{children}</div>
{footer && <div className="border-t border-border p-3">{footer}</div>}
</div>
</div>
</>,
document.body,
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
/**
* Send the selected passage to someone, from the reading toolbar.
*
* Two ways in, because they answer different questions:
*
* Frequent chats — people you already talk to. A grid of avatars, because
* you recognise these faces and want the shortest possible path.
* Friends — people you follow or who follow you but have never messaged.
* Rows with an explicit button, because starting a conversation is a bigger
* step than continuing one and shouldn't be a mis-tap away.
*
* Anyone with an existing thread is removed from Friends, so nobody appears
* twice and the second list is genuinely "people you haven't written to".
*/
import { useEffect, useState } from "react";
import { Check, Loader2, Send } 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 { ResponsiveDialog } from "@/components/chat/responsive-dialog";
import { chat, type ChatPeer, type QuotePayload } from "@/lib/chat";
import { relationships, type UserSummary } from "@/lib/social";
/** How many existing threads to offer before it stops being "frequent". */
const FREQUENT_LIMIT = 9;
/** Friends fetched from each side of the follow graph. */
const FRIENDS_PER_SIDE = 20;
type Frequent = { conversationId: string; peer: ChatPeer };
/** Per-person send state, keyed by handle. */
type SendState = "idle" | "sending" | "sent";
export function ShareToChatDialog({
body = "",
quote,
entity,
onClose,
}: {
/** Plain message text. Empty when sending a quote on its own. */
body?: string;
/** A passage from the library, sent as a tappable quotation. */
quote?: QuotePayload;
/** A post, sent as a card. Only the reference travels — the BFF resolves
* the contents and re-checks visibility for each viewer. */
entity?: { type: "post"; id: string };
onClose: () => void;
}) {
const { user } = useAuth();
const [frequent, setFrequent] = useState<Frequent[]>([]);
const [friends, setFriends] = useState<UserSummary[]>([]);
const [loaded, setLoaded] = useState(false);
// Derived rather than stored: with no session there is nothing to fetch, so
// "loading" is false immediately instead of needing an effect to say so.
const loading = !!user && !loaded;
const [states, setStates] = useState<Record<string, SendState>>({});
useEffect(() => {
// The toolbar gates this behind requireAuth; with no session there is
// nothing to fetch and `loading` is already false.
if (!user) return;
let cancelled = false;
(async () => {
// Existing threads first: they populate the grid AND tell us who to drop
// from Friends, so both lists come out of one pass.
const [inbox, followers, following] = await Promise.all([
chat.inbox("chats").catch(() => []),
relationships
.followers(user.handle, { limit: FRIENDS_PER_SIDE })
.catch(() => ({ users: [] as UserSummary[] })),
relationships
.following(user.handle, { limit: FRIENDS_PER_SIDE })
.catch(() => ({ users: [] as UserSummary[] })),
]);
if (cancelled) return;
// The inbox arrives newest-first, so "frequent" is really "most recent"
// — a reasonable stand-in until there's a message-count to rank by.
const dms: Frequent[] = inbox
.filter((c) => c.kind === "dm" && c.peer)
.slice(0, FREQUENT_LIMIT)
.map((c) => ({ conversationId: c.id, peer: c.peer as ChatPeer }));
const talkingTo = new Set(dms.map((d) => d.peer.handle));
const seen = new Set<string>();
const rest = [...following.users, ...followers.users].filter((u) => {
// Following before followers, so mutuals show up under the side you
// chose. Self can appear in neither.
if (u.handle === user.handle || talkingTo.has(u.handle)) return false;
if (seen.has(u.handle)) return false;
seen.add(u.handle);
return true;
});
setFrequent(dms);
setFriends(rest);
setLoaded(true);
})();
return () => {
cancelled = true;
};
}, [user]);
const mark = (handle: string, state: SendState) =>
setStates((prev) => ({ ...prev, [handle]: state }));
/** Sends into an existing thread, or opens one first. */
const sendTo = async (handle: string, conversationId?: string) => {
if (states[handle] === "sending" || states[handle] === "sent") return;
mark(handle, "sending");
try {
const id = conversationId ?? (await chat.openDM(handle)).id;
await chat.send(id, body, { quote, entity });
mark(handle, "sent");
} catch {
mark(handle, "idle");
toast.error("Could not send that message");
}
};
const sentCount = Object.values(states).filter((s) => s === "sent").length;
return (
<ResponsiveDialog
title="Send to a chat"
description="Share this passage in a conversation."
onClose={onClose}
footer={
<Button variant="outline" className="w-full" onClick={onClose}>
{sentCount > 0 ? "Done" : "Cancel"}
</Button>
}
>
{loading ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : frequent.length === 0 && friends.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
No one to send to yet. Follow someone to start a conversation.
</p>
) : (
<>
{frequent.length > 0 && (
<section className="p-4">
<h3 className="mb-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Frequent chats
</h3>
<ul className="grid grid-cols-3 gap-3">
{frequent.map(({ conversationId, peer }) => {
const state = states[peer.handle] ?? "idle";
return (
<li key={peer.handle}>
<button
type="button"
onClick={() => sendTo(peer.handle, conversationId)}
disabled={state !== "idle"}
className="flex w-full flex-col items-center gap-1.5 rounded-lg p-2 text-center hover:bg-muted/60 disabled:opacity-70"
>
<span className="relative">
<UserAvatar
src={peer.avatar_url}
name={peer.name}
className="size-14"
/>
{state !== "idle" && (
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
{state === "sending" ? (
<Loader2 className="size-4 animate-spin text-foreground" />
) : (
<Check className="size-5 text-primary" />
)}
</span>
)}
</span>
<span className="w-full truncate text-xs text-foreground">
{peer.name}
</span>
</button>
</li>
);
})}
</ul>
</section>
)}
{friends.length > 0 && (
<section className={frequent.length > 0 ? "border-t border-border" : ""}>
<h3 className="px-4 pb-2 pt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Friends
</h3>
<ul className="divide-y divide-border">
{friends.map((u) => {
const state = states[u.handle] ?? "idle";
return (
<li
key={u.handle}
className="flex items-center gap-3 px-4 py-2.5"
>
<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>
<Button
size="sm"
variant={state === "sent" ? "outline" : "default"}
disabled={state !== "idle"}
onClick={() => sendTo(u.handle)}
className="shrink-0"
>
{state === "sending" ? (
<Loader2 className="size-4 animate-spin" />
) : state === "sent" ? (
<>
<Check className="size-4" /> Sent
</>
) : (
<>
<Send className="size-4" /> Send
</>
)}
</Button>
</li>
);
})}
</ul>
</section>
)}
</>
)}
</ResponsiveDialog>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
/**
* A post shared into a chat, rendered as a card with its author.
*
* The contents are resolved by the BFF on every read, per viewer — never from
* a snapshot frozen at send time. So a post that has since been deleted or
* hidden arrives as `entity_unavailable` and renders as a tombstone rather
* than quietly showing content the reader is no longer allowed to see.
*/
import Link from "next/link";
import { ImageOff } from "lucide-react";
import { UserAvatar } from "@/components/ui/user-avatar";
import type { Attachment } from "@/lib/chat";
export function SharedPostCard({ attachment }: { attachment: Attachment }) {
const post = attachment.entity;
if (!post || attachment.entity_unavailable) {
return (
<div className="flex w-full items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5 sm:max-w-64">
<ImageOff className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="text-sm text-muted-foreground">
This post is no longer available
</span>
</div>
);
}
return (
<Link
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`}
// Capped on wide screens. The bubble allows 78% of the column, which on
// a phone is a sensible ~265px but on desktop is over 500px — and with
// a square image that makes a shared post taller than the viewport.
// Mobile keeps the bubble's own width; only sm+ needs the ceiling.
className="block w-full overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/40 sm:max-w-64"
>
<span className="flex items-center gap-2 px-3 py-2">
<UserAvatar
src={post.author_avatar_url ?? null}
name={post.author_name}
className="size-6 shrink-0"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-foreground">
{post.author_name}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
@{post.author_handle}
</span>
</span>
</span>
{post.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.image_url}
alt=""
loading="lazy"
className="aspect-square w-full object-cover"
/>
)}
{post.caption && (
<span className="block px-3 py-2 text-sm leading-relaxed text-foreground line-clamp-3">
{post.caption}
</span>
)}
{post.source_title && (
<span className="block truncate px-3 pb-2 text-xs text-muted-foreground">
{post.source_title}
</span>
)}
</Link>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
/**
* Stickers on a message, rendered without a chat bubble.
*
* Laid out as rows of at most two, NOT a fixed 2-column grid: a grid reserves
* the second cell whether or not anything fills it, so a single sticker sat
* beside a gap the width of another sticker. Rows size themselves — a lone
* sticker takes the row and is drawn larger, a pair shares it and both shrink.
* An odd count therefore ends on one big sticker rather than a hole.
*
* Unlike the media grid, this never collapses into "+N more" — every sticker
* sent is drawn. They are small and deliberately playful, and hiding some
* behind a counter would make a wall of them read as an attachment list
* rather than what it is.
*
* Long-pressing or right-clicking one offers to keep it, which is the only
* way a sticker spreads beyond the person who earned it.
*/
import { useState } from "react";
import Image from "next/image";
import { toast } from "sonner";
import { chat, type Attachment } from "@/lib/chat";
import { cn } from "@/lib/utils";
/** At most two per row, as WhatsApp and Telegram do. */
const STICKERS_PER_ROW = 2;
export function StickerGrid({
conversationId,
messageId,
stickers,
/** Own stickers are already in your collection — no point offering to save. */
mine,
}: {
conversationId: string;
messageId: string;
stickers: Attachment[];
mine: boolean;
}) {
const [saving, setSaving] = useState<string | null>(null);
if (stickers.length === 0) return null;
const save = async (a: Attachment) => {
const key = a.entity_id;
if (!key || mine || saving) return;
setSaving(key);
try {
await chat.saveSticker(conversationId, messageId, key);
toast.success(`Saved ${a.title ?? "sticker"} to your collection`);
} catch {
toast.error("Could not save that sticker");
} finally {
setSaving(null);
}
};
// Chunk into rows of at most two. Doing this in JS rather than CSS is what
// lets a row know how many it holds, which is what sets the size.
const rows: Attachment[][] = [];
for (let i = 0; i < stickers.length; i += STICKERS_PER_ROW) {
rows.push(stickers.slice(i, i + STICKERS_PER_ROW));
}
return (
// w-fit so the block hugs its rows: any fixed width would reintroduce the
// empty space this layout exists to remove.
<div className="flex w-fit flex-col gap-1">
{rows.map((row, i) => (
<div key={i} className="flex gap-1">
{row.map((a) => {
const alone = row.length === 1;
return (
<button
key={a.id}
type="button"
disabled={mine}
onContextMenu={(e) => {
if (mine) return;
e.preventDefault();
void save(a);
}}
title={
mine
? (a.title ?? "")
: `${a.title ?? "Sticker"} — hold to save`
}
aria-label={a.title ?? "Sticker"}
className={cn(
"relative disabled:cursor-default",
alone ? "size-32 sm:size-36" : "size-24 sm:size-28",
)}
>
{a.image_url && (
<Image
src={a.image_url}
alt={a.title ?? ""}
fill
sizes={alone ? "144px" : "112px"}
className="object-contain"
/>
)}
</button>
);
})}
</div>
))}
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
/**
* The sticker picker: a popover anchored to the composer's sticker button on
* desktop, a bottom drawer on mobile.
*
* A popover rather than a modal on desktop because picking a sticker is a
* quick aside from typing — a modal takes over the screen and hides the
* thread you're replying to. On touch, where there is nothing to anchor to
* and the keyboard is already occupying the lower half, the drawer is the
* right shape.
*
* The collection is whatever the server says it is — rewards earned through
* sādhana plus stickers saved from a chat — so a sticker you were sent
* appears the moment you keep it, with no client-side bookkeeping.
*
* Empty is a real state worth explaining: stickers are earned, so someone
* with none needs to know how to get one rather than seeing a blank sheet.
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { chat, type Sticker } from "@/lib/chat";
/** Where the popover hangs from — the sticker button's viewport rect. */
export type StickerAnchor = { x: number; y: number };
const POPOVER_W = 320;
const POPOVER_H = 300;
const EDGE_PAD = 8;
export function StickerPicker({
anchor,
onPick,
onClose,
}: {
/** Ignored on mobile, which uses a drawer. */
anchor: StickerAnchor | null;
onPick: (sticker: Sticker) => void;
onClose: () => void;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
const [stickers, setStickers] = useState<Sticker[] | null>(null);
useEffect(() => {
let cancelled = false;
chat
.stickers()
.then((s) => {
if (!cancelled) setStickers(s);
})
.catch(() => {
if (!cancelled) setStickers([]);
});
return () => {
cancelled = true;
};
}, []);
// Desktop only: Escape closes. The drawer handles its own dismissal, and no
// scroll lock here — a popover shouldn't freeze the thread behind it.
useEffect(() => {
if (isMobile) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isMobile, onClose]);
const grid = (
<>
{stickers === null ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : stickers.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
No stickers yet. Earn through your sādhana streak, or save one when
someone sends you.
</p>
) : (
<div className="grid grid-cols-4 gap-2 p-3 sm:grid-cols-5">
{stickers.map((s) => (
<button
key={s.key}
type="button"
onClick={() => {
onPick(s);
onClose();
}}
title={s.name}
aria-label={s.name}
className="relative aspect-square rounded-lg p-1 transition-colors hover:bg-muted"
>
<Image
src={s.image_url}
alt=""
fill
sizes="80px"
className="object-contain p-1"
/>
</button>
))}
</div>
)}
</>
);
if (isMobile) {
return (
<Drawer open onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left">
<DrawerTitle>Stickers</DrawerTitle>
<DrawerDescription>
Earned through sādhana, or saved from a chat.
</DrawerDescription>
</DrawerHeader>
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
{grid}
</div>
</DrawerContent>
</Drawer>
);
}
if (typeof document === "undefined" || !anchor) return null;
// Opens UPWARD by default: the composer sits at the bottom of the viewport,
// so there is rarely room below it. Flips down only if the anchor is high
// enough that upward would clip.
let left = anchor.x - POPOVER_W / 2;
if (left + POPOVER_W > window.innerWidth - EDGE_PAD) {
left = window.innerWidth - POPOVER_W - EDGE_PAD;
}
if (left < EDGE_PAD) left = EDGE_PAD;
// Room above the button, less a small gap and the viewport edge.
const roomAbove = anchor.y - 8 - EDGE_PAD;
// Open upward when there is any reasonable room, which by the composer
// there almost always is.
const openUp = roomAbove > 160;
// Anchored by whichever edge faces the button, so the panel hugs it
// regardless of how tall its contents actually are. Positioning by top
// minus a MAX height floats it away whenever the content is shorter.
const vertical = openUp
? { bottom: window.innerHeight - anchor.y + 8 }
: { top: anchor.y + 8 };
// Never taller than the space it has.
const maxH = Math.min(
POPOVER_H,
openUp ? roomAbove : window.innerHeight - anchor.y - 8 - EDGE_PAD,
);
return createPortal(
<>
{/* Catches the outside click. Transparent — a popover dims nothing. */}
<div className="fixed inset-0 z-50" onClick={onClose} aria-hidden />
<div
role="dialog"
aria-label="Stickers"
style={{ left, ...vertical, width: POPOVER_W, maxHeight: maxH }}
className="fixed z-[51] flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg"
>
<div className="border-b border-border px-3 py-2">
<p className="text-xs font-medium text-foreground">Stickers</p>
<p className="text-[11px] text-muted-foreground">
Earned through sādhana, or saved from a chat.
</p>
</div>
<div className="flex-1 overflow-y-auto">{grid}</div>
</div>
</>,
document.body,
);
}
+9 -3
View File
@@ -5,7 +5,8 @@
* library.
*
* Two sections, top to bottom:
* 1. Start here — three primary entry points (Books, Letters, Lectures)
* 1. Start here — a Quizzes tile, then three primary entry points
* (Books, Letters, Lectures), all one width
* rendered as full-width feature tiles with longer copy.
* 2. Explore — a six-card asymmetric grid for the remaining sections.
*
@@ -26,6 +27,7 @@ import {
PenLine,
Users,
} from "lucide-react";
import { QuizzesCTA } from "@/components/quiz/quizzes-cta";
type Feature = {
href: string;
@@ -112,7 +114,11 @@ export function KnowledgebaseIndex() {
<div>
<section className="py-4">
<SectionHeading eyebrow="Start here" title="Three doors in" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5">
{/* One grid so all four share a width. Quizzes lead: something to
do, ahead of the three ways in to read. Absent until one is
published, leaving the original three across. */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-none lg:grid-flow-col lg:auto-cols-fr gap-4 sm:gap-5">
<QuizzesCTA variant="tile" />
{FEATURED.map((f) => (
<FeaturedTile key={f.href} feature={f} />
))}
@@ -145,7 +151,7 @@ function FeaturedTile({ feature }: { feature: Feature }) {
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
</div>
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] leading-tight text-foreground">
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] lg:text-[1.35rem] leading-tight text-foreground">
{feature.label}
</h3>
+29
View File
@@ -0,0 +1,29 @@
"use client";
/**
* The column that holds the header, page and footer.
*
* Exists as a client component for one reason: the bottom padding that
* reserves space for the mobile tab bar has to disappear wherever the tab bar
* does, and that decision depends on the route. Left in the server layout, the
* padding would survive the bar and leave a 64px gap under a chat composer.
*/
import { cn } from "@/lib/utils";
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
export function AppShell({ children }: { children: React.ReactNode }) {
const noTabBar = useHidesAppChrome();
return (
<div
className={cn(
"flex flex-1 flex-col md:pl-16",
// Matches SiteBottomNav's own visibility exactly.
noTabBar ? "pb-0" : "pb-16 md:pb-0",
)}
>
{children}
</div>
);
}
@@ -0,0 +1,79 @@
"use client";
/**
* "Copy link to this page" — copies the full, unshortened absolute URL of
* the active page. Deliberately bypasses useCopyFeedback (which rewrites
* `?hl=` highlight URLs into short /share links); here we want the literal
* page URL. The write is synchronous within the click so a single tap works.
*
* Shared by the PageHeader controls row and pages with their own header
* (e.g. a quiz).
*/
import { useEffect, useRef, useState } from "react";
import { Check, Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
export function CopyPageLinkButton({
label = "Copy link to this page",
className,
}: {
label?: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
};
}, []);
async function copyPageLink() {
const url = window.location.href;
try {
await navigator.clipboard.writeText(url);
} catch {
// Fallback for browsers without the async clipboard API.
const ta = document.createElement("textarea");
ta.value = url;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand("copy");
} catch {
// silent fail
}
document.body.removeChild(ta);
}
setCopied(true);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
}
return (
<button
type="button"
onClick={copyPageLink}
aria-label={label}
title={label}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs transition-colors",
copied
? "text-foreground border-primary/40"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
className,
)}
>
{copied ? (
<Check className="size-3.5 text-primary" />
) : (
<Link2 className="size-3.5" />
)}
{copied ? "Link copied" : label}
</button>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/**
* The unread badge on a nav entry.
*
* Counts threads waiting on you, not messages: one busy group shouldn't read
* as twenty things needing attention. Muted threads are excluded server-side,
* so muting a noisy group clears the badge while its unread count stays
* visible in the inbox.
*
* Wraps its icon rather than being positioned by each caller, so the sidebar
* and bottom bar can't drift apart.
*/
import { useNotifications } from "@/components/notifications/notifications-provider";
export function NavUnreadBadge({ children }: { children: React.ReactNode }) {
const { chatUnread } = useNotifications();
if (chatUnread <= 0) return <>{children}</>;
return (
<span className="relative inline-flex">
{children}
<span
// Announced through the link's aria-label instead; this is decoration.
aria-hidden
className="absolute -right-1.5 -top-1 min-w-4 rounded-full bg-primary px-1 text-center text-[10px] font-semibold leading-4 text-white ring-2 ring-background"
>
{chatUnread > 9 ? "9+" : chatUnread}
</span>
</span>
);
}
/** The count, for callers that need it in an accessible name. */
export function useChatUnread(): number {
return useNotifications().chatUnread;
}
+14 -57
View File
@@ -25,9 +25,10 @@
import { useEffect, useRef, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Check, Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { CopyPageLinkButton } from "@/components/layout/copy-page-link-button";
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
import { QuizButton } from "@/components/quiz/quiz-button";
import type { OfflineDescriptor } from "@/lib/offline";
const SEARCH_RETURN_KEY = "spsays_from_search";
@@ -71,6 +72,7 @@ export function PageHeader({
bare = false,
sticky = false,
offline,
quiz = true,
}: {
children: React.ReactNode;
/** Drop the bordered chrome — useful for pages that want flush title text. */
@@ -89,6 +91,13 @@ export function PageHeader({
* visible gap.
*/
sticky?: boolean;
/**
* Show this page's quiz button (if it has a published quiz) under the
* title. On by default for content pages; a page that places the button
* itself — the transcript, beside its audio player — turns this off so it
* never appears twice.
*/
quiz?: boolean;
className?: string;
}) {
const router = useRouter();
@@ -144,43 +153,6 @@ export function PageHeader({
return () => window.removeEventListener("scroll", onScroll);
}, [sticky]);
// "Copy link to this page" — copies the full, unshortened absolute URL of
// the active page. Deliberately bypasses useCopyFeedback (which rewrites
// `?hl=` highlight URLs into short /share links); here we want the literal
// page URL. The write is synchronous within the click so a single tap works.
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
};
}, []);
async function copyPageLink() {
const url = window.location.href;
try {
await navigator.clipboard.writeText(url);
} catch {
// Fallback for browsers without the async clipboard API.
const ta = document.createElement("textarea");
ta.value = url;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand("copy");
} catch {
// silent fail
}
document.body.removeChild(ta);
}
setCopied(true);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
}
return (
<header
className={cn(
@@ -216,25 +188,10 @@ export function PageHeader({
{/* Page-level controls, anchored at the header's bottom. */}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={copyPageLink}
aria-label="Copy link to this page"
title="Copy link to this page"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs transition-colors",
copied
? "text-foreground border-primary/40"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{copied ? (
<Check className="size-3.5 text-primary" />
) : (
<Link2 className="size-3.5" />
)}
{copied ? "Link copied" : "Copy link to this page"}
</button>
{/* First, so it reads as belonging to the title above it. Renders
nothing on pages without a published quiz. */}
{quiz && isContentPath(pathname) ? <QuizButton path={pathname} /> : null}
<CopyPageLinkButton />
{resolvedOffline ? (
<SaveOfflineButton descriptor={resolvedOffline} />
) : null}
+23 -3
View File
@@ -14,7 +14,12 @@ import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Home, MessageCircle, Search, User as UserIcon } from "lucide-react";
import {
NavUnreadBadge,
useChatUnread,
} from "@/components/layout/nav-unread-badge";
import { useAuth } from "@/components/auth/auth-provider";
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
import { cn } from "@/lib/utils";
type Item = {
@@ -27,7 +32,11 @@ type Item = {
export function SiteBottomNav() {
const pathname = usePathname();
const hidden = useHidesAppChrome();
const { user } = useAuth();
// Read here rather than inside the badge so the count can also reach the
// link's accessible name — the badge itself is decorative.
const chatUnread = useChatUnread();
const items: Item[] = [
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
@@ -51,7 +60,7 @@ export function SiteBottomNav() {
],
},
{
href: "/chat",
href: "/chats",
label: "Chat",
icon: <MessageCircle className="size-5" />,
},
@@ -90,6 +99,9 @@ export function SiteBottomNav() {
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
// A chat thread owns the bottom edge — its composer lives there.
if (hidden) return null;
return (
<nav
aria-label="Primary"
@@ -113,7 +125,11 @@ export function SiteBottomNav() {
>
<Link
href={item.href}
aria-label={item.label}
aria-label={
item.href === "/chats" && chatUnread > 0
? `${item.label} (${chatUnread} unread)`
: item.label
}
aria-current={active ? "page" : undefined}
className={cn(
"w-12 h-12 rounded-md flex flex-col items-center justify-center",
@@ -122,7 +138,11 @@ export function SiteBottomNav() {
: "text-muted-foreground hover:text-foreground",
)}
>
{item.icon}
{item.href === "/chats" ? (
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
) : (
item.icon
)}
</Link>
</li>
);
+5
View File
@@ -26,6 +26,7 @@ import Link from "next/link";
import { ReadingSettings } from "@/components/reading-settings";
import { NotificationBell } from "@/components/layout/notification-bell";
import { useAuth } from "@/components/auth/auth-provider";
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
export function SiteHeader() {
const { user, loading } = useAuth();
@@ -33,6 +34,10 @@ export function SiteHeader() {
// auth check we treat `loading` as anonymous so the gear doesn't flash
// in and out — once auth resolves, it hides for signed-in users.
const showMobileGear = !loading && !user;
// A chat thread has its own header — back button, name, avatar — so the
// wordmark bar would stack a second title above it.
const hidden = useHidesAppChrome();
if (hidden) return null;
return (
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
+5
View File
@@ -17,6 +17,7 @@ import { useState } from "react";
import Link from "next/link";
import { Menu } from "lucide-react";
import { cn } from "@/lib/utils";
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
import {
Drawer,
DrawerContent,
@@ -43,6 +44,10 @@ export function MobileNavToggle({
items: ReadonlyArray<NavItem>;
}) {
const [open, setOpen] = useState(false);
// A thread already has a back button in its own header; a hamburger beside
// it offers a second, conflicting way out.
const hidden = useHidesAppChrome();
if (hidden) return null;
return (
<Drawer open={open} onOpenChange={setOpen} direction="left">
+17 -2
View File
@@ -40,6 +40,10 @@ import {
Settings2,
User as UserIcon,
} from "lucide-react";
import {
NavUnreadBadge,
useChatUnread,
} from "@/components/layout/nav-unread-badge";
import { useAuth } from "@/components/auth/auth-provider";
import { ReadingSettings } from "@/components/reading-settings";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
@@ -177,10 +181,15 @@ function SidebarLink({
* settings popover is up) and the mouse isn't actually hovering. */
expandedOverride?: boolean;
}) {
const chatUnread = useChatUnread();
return (
<Link
href={item.href}
aria-label={item.label}
aria-label={
item.href === "/chats" && chatUnread > 0
? `${item.label} (${chatUnread} unread)`
: item.label
}
title={item.label}
className={cn(
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
@@ -190,7 +199,13 @@ function SidebarLink({
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<span className="shrink-0">{item.icon}</span>
<span className="shrink-0">
{item.href === "/chats" ? (
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
) : (
item.icon
)}
</span>
<span
className={cn(
"transition-opacity duration-150",
@@ -0,0 +1,118 @@
"use client";
/**
* The announcement surface: a modal on desktop, a drawer on mobile.
*
* Shown at most once per user, and that guarantee lives on the server — the
* dismissal is a row, not local storage — so it survives a new device, a
* cleared browser, and two tabs racing.
*
* It appears on load and on the realtime nudge, but never over a signed-out
* page: an announcement is addressed to a person, and there is nobody to
* address until they're signed in.
*/
import { useCallback, useEffect, useState } from "react";
import Image from "next/image";
import { useAuth } from "@/components/auth/auth-provider";
import { useNotifications } from "@/components/notifications/notifications-provider";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
import { MessageText } from "@/components/chat/message-text";
import { Button } from "@/components/ui/button";
import { broadcasts, type Broadcast } from "@/lib/broadcasts";
export function BroadcastModal() {
const { user } = useAuth();
const { lastBroadcastAt } = useNotifications();
const [item, setItem] = useState<Broadcast | null>(null);
const [dismissing, setDismissing] = useState(false);
const load = useCallback(() => {
if (!user?.id) return;
broadcasts
.pending()
.then(setItem)
.catch(() => {
/* announcements are never worth an error surface */
});
}, [user?.id]);
// On sign-in, and again whenever the realtime nudge fires. lastBroadcastAt
// changes on each publish, so this refetches without the event carrying any
// content of its own.
useEffect(() => {
load();
}, [load, lastBroadcastAt]);
const dismiss = useCallback(async () => {
if (!item || dismissing) return;
setDismissing(true);
// Close first: the dismissal is idempotent and the server already knows
// what this user has seen, so a failed write costs at most one repeat
// rather than a modal that won't go away.
const id = item.id;
setItem(null);
setDismissing(false);
try {
await broadcasts.markSeen(id);
} catch {
/* it will simply be offered again */
}
}, [item, dismissing]);
// Guard on the shape, not just presence: a malformed payload must not put
// an empty modal over every page in the app.
if (!item?.id || !item.title) return null;
return (
<ResponsiveDialog
title={item.title}
description=""
onClose={dismiss}
footer={
<div className="flex gap-2">
{item.cta_url && item.cta_label ? (
<>
<Button variant="outline" className="flex-1" onClick={dismiss}>
Not now
</Button>
<a
href={item.cta_url}
// Dismiss on the way out, so following the link also counts as
// having seen it.
onClick={dismiss}
{...(item.cta_url.startsWith("/")
? {}
: { target: "_blank", rel: "noopener noreferrer" })}
className="inline-flex flex-1 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 dark:text-white"
>
{item.cta_label}
</a>
</>
) : (
<Button className="w-full" onClick={dismiss}>
Got it
</Button>
)}
</div>
}
>
<div className="space-y-3 p-4">
{item.banner_url && (
<div className="relative aspect-[2/1] w-full overflow-hidden rounded-lg bg-muted">
<Image
src={item.banner_url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 28rem"
className="object-cover"
/>
</div>
)}
<div className="text-sm leading-relaxed text-foreground">
<MessageText body={item.body} entities={item.entities ?? []} />
</div>
</div>
</ResponsiveDialog>
);
}
@@ -50,6 +50,15 @@ const EVENTS: {
label: "Sādhana reminders",
description: "Daily nudges to chant your rounds and share a verse.",
},
{
key: "messages",
label: "Messages",
description:
"New messages. Email is only ever sent when someone starts a conversation with you.",
// No in-app: chat's in-app delivery is the inbox itself, where a message
// highlights its row rather than entering the notification tray.
channels: ["email", "push"],
},
{
key: "community",
label: "Community",
@@ -130,7 +139,11 @@ export function NotificationMatrix() {
}
return (
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-x-4 gap-y-0 sm:gap-x-6">
// Fixed, EQUAL channel columns. With `auto` each one sized to its own
// widest content — "In-app" against the 44px toggle, "Push" against the
// same toggle — so the three columns came out different widths and
// neither the headers nor the switches lined up down the grid.
<div className="grid grid-cols-[1fr_repeat(3,3.25rem)] items-center gap-x-2 gap-y-0 sm:grid-cols-[1fr_repeat(3,4rem)] sm:gap-x-4">
{/* Header row */}
<span aria-hidden />
{CHANNELS.map((c) => (
@@ -34,6 +34,7 @@ import { useAuth } from "@/components/auth/auth-provider";
import { channelAuthorizer } from "@/lib/realtime";
import { feed, notifications } from "@/lib/social";
import { sadhna } from "@/lib/sadhna";
import { chat } from "@/lib/chat";
type NotificationsContextValue = {
/** Live unread count for the header bell. */
@@ -51,6 +52,14 @@ type NotificationsContextValue = {
* event name and is surfaced here rather than bumping `unread`.
*/
lastChatActivity: ChatActivity | null;
/** Bumps when an announcement is published. Carries no content — consumers
* refetch /broadcasts/pending, which answers per user. */
lastBroadcastAt: number;
/** Threads holding unread messages — the Chats nav badge. Muted threads are
* excluded by the BFF, so muting a busy group clears the badge. */
chatUnread: number;
/** Re-read the authoritative chat count (after opening a thread). */
refreshChatUnread: () => void;
};
/** Payload of the BFF's `chat_activity` event. */
@@ -108,6 +117,8 @@ export function NotificationsProvider({
}) {
const { user } = useAuth();
const [unread, setUnread] = useState(0);
const [chatUnread, setChatUnread] = useState(0);
const [lastBroadcastAt, setLastBroadcastAt] = useState(0);
const [lastEventAt, setLastEventAt] = useState(0);
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
null,
@@ -124,12 +135,22 @@ export function NotificationsProvider({
const clearUnread = useCallback(() => setUnread(0), []);
const refreshChatUnread = useCallback(() => {
chat
.unreadCount()
.then(setChatUnread)
.catch(() => {
/* badge is non-critical */
});
}, []);
// Seed the count whenever the signed-in user changes (async — no
// synchronous setState in the effect body).
useEffect(() => {
if (!user?.id) return;
refreshUnread();
}, [user?.id, refreshUnread]);
refreshChatUnread();
}, [user?.id, refreshUnread, refreshChatUnread]);
// Report the browser's timezone (once per signed-in session) so sādhana
// reminders fire at the user's local clock time. Best-effort.
@@ -158,7 +179,7 @@ export function NotificationsProvider({
};
window.addEventListener("pagehide", onLeave);
return () => window.removeEventListener("pagehide", onLeave);
}, [userId]);
}, [userId, refreshChatUnread]);
// Open the realtime connection while signed in.
useEffect(() => {
@@ -197,13 +218,24 @@ export function NotificationsProvider({
channel.bind("pusher:subscription_error", (status: unknown) => {
console.warn("notifications: realtime subscription failed", status);
});
// Chat activity: no toast, no unread bump, nothing in the tray. The
// inbox subscribes to this to highlight the affected row.
// Chat activity: no toast, nothing in the tray. The inbox subscribes to
// this to highlight the affected row, and the nav badge re-reads its
// count — refetched rather than incremented, because the badge counts
// THREADS: a second message in a thread already counted must not bump
// it, and the sender's own message must not either.
channel.bind(
"chat_activity",
(data: Omit<ChatActivity, "at">) => {
if (!data?.conversation_id) return;
setLastChatActivity({ ...data, at: Date.now() });
refreshChatUnread();
// Ack delivery here rather than in the thread, so "delivered" means
// "reached their device" rather than "they happened to have the chat
// open". This event only reaches non-senders, so nobody acks their
// own message.
if (data.seq) {
chat.markDelivered(data.conversation_id, data.seq).catch(() => {});
}
},
);
channel.bind("notification", (data: IncomingEvent) => {
@@ -221,7 +253,18 @@ export function NotificationsProvider({
});
},
);
// Announcements ride a PUBLIC channel: one publish reaches everyone in
// a single Soketi call rather than one per user, and the event is safe
// to broadcast because it carries nothing — the content comes from the
// authenticated fetch it triggers.
const announcements = pusher.subscribe("broadcasts");
announcements.bind("broadcast_published", () =>
setLastBroadcastAt(Date.now()),
);
cleanup = () => {
announcements.unbind_all();
pusher.unsubscribe("broadcasts");
channel.unbind_all();
pusher.unsubscribe(channelName);
pusher.disconnect();
@@ -232,7 +275,7 @@ export function NotificationsProvider({
cancelled = true;
cleanup?.();
};
}, [userId]);
}, [userId, refreshChatUnread]);
return (
<NotificationsContext.Provider
@@ -242,6 +285,9 @@ export function NotificationsProvider({
refreshUnread,
clearUnread,
lastChatActivity,
chatUnread,
refreshChatUnread,
lastBroadcastAt,
}}
>
{children}
+213
View File
@@ -0,0 +1,213 @@
"use client";
/**
* The "All" tab of /quizzes, as a drill-down:
*
* All → Books → a book → its quizzes
* → Lectures & Conversations → a year → its quizzes
* → Other quizzes (stand-alone)
*
* Where you are lives in the URL (?in=books&book=bg, ?in=lectures&year=1972)
* so a refresh, Back, or a shared link lands in the same place. Every level
* only offers categories that actually hold quizzes; a stale or hand-typed
* URL pointing at an empty one falls back to its parent.
*/
import Link from "next/link";
import {
BookOpen,
CalendarDays,
ChevronRight,
ClipboardCheck,
Mic,
} from "lucide-react";
import type { QuizCard } from "@/lib/quiz";
import { groupQuizzes, type QuizGroup } from "@/lib/quiz-groups";
import { QuizCategoryCard } from "./quiz-category-card";
import { QuizGrid } from "./quiz-grid";
export type QuizBrowsePath = {
in?: string | null;
book?: string | null;
year?: string | null;
};
const BOOKS = "books";
const LECTURES = "lectures";
const OTHER = "other";
/** /quizzes URL for a browse position (the All tab is the default). */
export function quizzesHref(p: QuizBrowsePath): string {
const qs = new URLSearchParams();
if (p.in) qs.set("in", p.in);
if (p.book) qs.set("book", p.book);
if (p.year) qs.set("year", p.year);
const s = qs.toString();
return s ? `/quizzes?${s}` : "/quizzes";
}
export function QuizBrowser({
quizzes,
path,
}: {
quizzes: QuizCard[];
path: QuizBrowsePath;
}) {
const { books, lectures, other } = groupQuizzes(quizzes);
const all = (gs: QuizGroup[]) => gs.flatMap((g) => g.quizzes);
const book =
path.in === BOOKS ? books.find((g) => g.key === path.book) : undefined;
const year =
path.in === LECTURES
? lectures.find((g) => g.key === path.year)
: undefined;
// Books → a book: its quizzes.
if (book) {
return (
<Level crumbs={[["Books", quizzesHref({ in: BOOKS })], [book.label]]}>
<QuizGrid quizzes={book.quizzes} />
</Level>
);
}
// Lectures → a year: its quizzes.
if (year) {
return (
<Level
crumbs={[
["Lectures & Conversations", quizzesHref({ in: LECTURES })],
[year.label],
]}
>
<QuizGrid quizzes={year.quizzes} />
</Level>
);
}
// Books: a card per book.
if (path.in === BOOKS && books.length > 0) {
return (
<Level crumbs={[["Books"]]}>
<CardGrid>
{books.map((g) => (
<QuizCategoryCard
key={g.key}
href={quizzesHref({ in: BOOKS, book: g.key })}
label={g.label}
quizzes={g.quizzes}
icon={<BookOpen className="size-5" />}
/>
))}
</CardGrid>
</Level>
);
}
// Lectures: a card per year.
if (path.in === LECTURES && lectures.length > 0) {
return (
<Level crumbs={[["Lectures & Conversations"]]}>
<CardGrid>
{lectures.map((g) => (
<QuizCategoryCard
key={g.key}
href={quizzesHref({ in: LECTURES, year: g.key })}
label={g.label}
quizzes={g.quizzes}
icon={<CalendarDays className="size-5" />}
/>
))}
</CardGrid>
</Level>
);
}
// Stand-alone quizzes.
if (path.in === OTHER && other.length > 0) {
return (
<Level crumbs={[["Other quizzes"]]}>
<QuizGrid quizzes={other} />
</Level>
);
}
// Top level: one card per category that has quizzes.
return (
<CardGrid>
{books.length > 0 && (
<QuizCategoryCard
href={quizzesHref({ in: BOOKS })}
label="Books"
quizzes={all(books)}
icon={<BookOpen className="size-5" />}
/>
)}
{lectures.length > 0 && (
<QuizCategoryCard
href={quizzesHref({ in: LECTURES })}
label="Lectures & Conversations"
quizzes={all(lectures)}
icon={<Mic className="size-5" />}
/>
)}
{other.length > 0 && (
<QuizCategoryCard
href={quizzesHref({ in: OTHER })}
label="Other quizzes"
quizzes={other}
icon={<ClipboardCheck className="size-5" />}
/>
)}
</CardGrid>
);
}
function CardGrid({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 [&>*]:min-w-0">
{children}
</div>
);
}
/** A level below the top: a breadcrumb back up, then its content. */
function Level({
crumbs,
children,
}: {
/** [label, href?] — the last (current) one has no link. */
crumbs: [string, string?][];
children: React.ReactNode;
}) {
return (
<div className="space-y-4">
<nav
aria-label="Quiz categories"
className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
>
<Link
href={quizzesHref({})}
className="text-muted-foreground hover:text-foreground"
>
All quizzes
</Link>
{crumbs.map(([label, href]) => (
<span key={label} className="flex min-w-0 items-center gap-1.5">
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
{href ? (
<Link
href={href}
className="truncate text-muted-foreground hover:text-foreground"
>
{label}
</Link>
) : (
<span className="truncate font-medium text-foreground">
{label}
</span>
)}
</span>
))}
</nav>
{children}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
/**
* The quiz button on a content page.
*
* Asks the BFF whether this page has a published quiz and renders nothing if
* not, so it can sit on every content page without leaving an empty slot.
* Quizzes are for signed-in readers only, so signed out it never asks.
* Its label tracks progress: Take quiz → Continue quiz → View results.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { ClipboardCheck } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { quiz, quizActionLabel, type QuizCard } from "@/lib/quiz";
import { cn } from "@/lib/utils";
export function QuizButton({
path,
size = "sm",
className,
}: {
/** The content page's own path, e.g. /books/bg/2. */
path: string;
/**
* "sm" for the page header's controls row; "lg" to sit beside the lecture
* page's Play Audio button as an equal — same height, padding, type and
* colours.
*/
size?: "sm" | "lg";
className?: string;
}) {
const { user } = useAuth();
const userId = user?.id;
const [card, setCard] = useState<QuizCard | null>(null);
// Re-asked when the user changes, so progress appears right after sign-in.
useEffect(() => {
if (!userId) return;
let cancelled = false;
quiz
.forPath(path)
.then((c) => {
if (!cancelled) setCard(c);
})
.catch(() => {
/* no quiz button is the right failure */
});
return () => {
cancelled = true;
};
}, [path, userId]);
// A card left over from before sign-out is ignored rather than cleared.
if (!card || !user) return null;
const done = card.my.state === "completed";
const lg = size === "lg";
return (
<Link
href={`/quizzes/${card.id}`}
title={card.title}
className={cn(
"inline-flex items-center border transition-colors",
lg
? // Mirrors PlayAudioButton's default variant. That has no border,
// so 1px less vertical padding keeps the two exactly as tall.
"gap-2 rounded-lg px-5 py-[9px] text-sm font-semibold transition-all"
: "gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium",
done
? "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
: // Take / Continue: solid, so a pending quiz stands out.
cn(
"border-primary bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
// Header pill keeps white text in dark mode; beside Play Audio
// it takes Play Audio's own foreground so the pair match.
lg ? "hover:shadow-md" : "dark:text-white",
),
className,
)}
>
<ClipboardCheck className={lg ? "size-4" : "size-3.5"} />
{quizActionLabel(card.my.state)}
{card.my.state === "in_progress" && (
<span className="tabular-nums opacity-70">
{card.my.answered}/{card.my.total}
</span>
)}
{done && card.my.score !== undefined && (
<span className="tabular-nums opacity-70">
{card.my.score}/{card.my.total}
</span>
)}
</Link>
);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* A clickable category in the quiz browser — Books, a book, Lectures, a
* year. Shows how many quizzes it holds and how many are still to take.
*/
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import type { QuizCard } from "@/lib/quiz";
export function QuizCategoryCard({
href,
label,
quizzes,
icon,
}: {
href: string;
label: string;
quizzes: QuizCard[];
icon: React.ReactNode;
}) {
const todo = quizzes.filter((q) => q.my.state !== "completed").length;
const n = quizzes.length;
return (
<Link
href={href}
className="group flex h-full min-w-0 items-center gap-3.5 rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-muted/30"
>
<span className="inline-flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
{icon}
</span>
<span className="min-w-0 flex-1">
<span
title={label}
className="line-clamp-2 font-semibold leading-snug text-foreground [overflow-wrap:anywhere]"
>
{label}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{n} {n === 1 ? "quiz" : "quizzes"}
{todo > 0 && todo < n ? ` · ${todo} to do` : ""}
{todo === 0 ? " · all finished" : ""}
</span>
</span>
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
</Link>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { QuizCard } from "@/lib/quiz";
import { QuizListCard } from "./quiz-list-card";
/**
* Quiz cards, two across from sm up. minmax(0,1fr) + min-w-0: grid items
* otherwise refuse to shrink below their content, so one long word pushes a
* card off-screen.
*/
export function QuizGrid({ quizzes }: { quizzes: QuizCard[] }) {
return (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{quizzes.map((q) => (
<li key={q.id} className="min-w-0">
<QuizListCard q={q} />
</li>
))}
</ul>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
/** One quiz in the /quizzes list: what it's on, its title, and your progress. */
import Link from "next/link";
import { ClipboardCheck } from "lucide-react";
import { quizActionLabel, type QuizCard } from "@/lib/quiz";
import { cn } from "@/lib/utils";
const KIND: Record<QuizCard["target_kind"], string> = {
chapter: "Book chapter",
lecture: "Lecture",
conversation: "Conversation",
standalone: "Quiz",
};
export function QuizListCard({ q }: { q: QuizCard }) {
const done = q.my.state === "completed";
return (
<Link
href={`/quizzes/${q.id}`}
className="flex h-full min-w-0 flex-col rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-muted/30"
>
<p className="text-xs font-medium text-primary">{KIND[q.target_kind]}</p>
<p className="mt-1 break-words font-semibold leading-snug text-foreground [overflow-wrap:anywhere]">
{q.title}
</p>
{q.target_title && (
<p className="mt-0.5 truncate text-sm text-muted-foreground">
{q.target_title}
</p>
)}
<div className="mt-auto flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pt-4 text-xs">
<span className="text-muted-foreground">
{q.question_count} {q.question_count === 1 ? "question" : "questions"}
</span>
<span
className={cn(
"inline-flex items-center gap-1 whitespace-nowrap font-medium",
done ? "text-muted-foreground" : "text-primary",
)}
>
<ClipboardCheck className="size-3.5" />
{quizActionLabel(q.my.state)}
{done && q.my.score !== undefined && ` · ${q.my.score}/${q.my.total}`}
</span>
</div>
</Link>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
/**
* Your finished attempt: the score, then every question with your answer,
* the correct one, its explanation, and how long you took.
*
* Correct options are filled solid so they read at a glance; a wrong pick is
* outlined in red. "Your answer" marks whatever you chose either way.
*/
import Link from "next/link";
import { Check, Clock, X } from "lucide-react";
import { RichText } from "@/lib/rich-text";
import type { QuizResult } from "@/lib/quiz";
import { cn } from "@/lib/utils";
/**
* "8.4s" / "1m 12s" / "3h 20m" / "2d 4h". Hours past 23h 59m roll up into
* days. Mirrors fmtMs in the admin's quizzes/labels.ts.
*/
export function fmtMs(ms?: number | null): string {
if (ms == null) return "—";
// Below 59.95s: one decimal place (anything higher would print "60.0s").
if (ms < 59_950) return `${(ms / 1000).toFixed(1)}s`;
// Whole seconds from here, so rounding can't print "1m 60s".
const total = Math.round(ms / 1000);
const d = Math.floor(total / 86_400);
const h = Math.floor((total % 86_400) / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
// The two largest units: 2d 4h · 3h 20m · 12m 5s.
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${s}s`;
}
export function QuizResultView({ result }: { result: QuizResult }) {
const pct = result.total
? Math.round((result.score / result.total) * 100)
: 0;
return (
<div className="space-y-6">
<section className="rounded-2xl border border-border bg-card p-5 text-center">
<p className="text-xs font-medium uppercase tracking-wide text-primary">
Your result
</p>
<p className="mt-2 text-4xl font-semibold tabular-nums text-foreground">
{result.score}
<span className="text-2xl text-muted-foreground">
/{result.total}
</span>
</p>
<p className="mt-1 text-sm text-muted-foreground">
{pct}% correct · {fmtMs(result.total_ms)} in total
</p>
{result.quiz.target_path && (
<Link
href={result.quiz.target_path}
className="mt-3 inline-block text-sm font-medium text-primary hover:underline"
>
Back to {result.quiz.target_title || "the reading"}
</Link>
)}
</section>
<ol className="space-y-4">
{result.items.map((it, i) => (
<li key={it.id} className="rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium text-foreground">
<span className="text-muted-foreground">{i + 1}. </span>
<RichText text={it.prompt} />
</p>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 text-xs",
it.is_correct ? "text-primary" : "text-destructive",
)}
>
{it.is_correct ? (
<Check className="size-3.5" />
) : (
<X className="size-3.5" />
)}
{it.is_correct ? "Correct" : "Incorrect"}
</span>
</div>
{it.multi && (
<p className="mt-1 text-xs text-muted-foreground">
Select all that apply
</p>
)}
<ul className="mt-3 space-y-1.5">
{it.options.map((o) => {
const chosen = it.chosen_option_ids.includes(o.id);
return (
<li
key={o.id}
className={cn(
"flex items-start gap-2 rounded-lg border px-3 py-2 text-sm",
o.is_correct
? "border-primary bg-primary font-medium text-primary-foreground dark:text-white"
: chosen
? "border-destructive/50 bg-destructive/10 text-foreground"
: "border-border text-muted-foreground",
)}
>
{o.is_correct ? (
<Check className="mt-0.5 size-3.5 shrink-0" strokeWidth={3} />
) : chosen ? (
<X className="mt-0.5 size-3.5 shrink-0 text-destructive" />
) : (
<span className="size-3.5 shrink-0" />
)}
<span className="min-w-0 flex-1">
<RichText text={o.body} />
</span>
{chosen && (
<span
className={cn(
"shrink-0 text-xs font-normal",
o.is_correct ? "opacity-85" : "text-muted-foreground",
)}
>
Your answer
</span>
)}
</li>
);
})}
</ul>
{it.explanation && (
<p className="mt-3 rounded-lg bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<RichText text={it.explanation} />
</p>
)}
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="size-3" /> {fmtMs(it.response_ms)}
</p>
</li>
))}
</ol>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
/**
* QuizzesCTA — the way into /quizzes, in two shapes:
*
* - "tile": a card that leads the search page's "Start here" row.
* - "banner": a full-width row for the homepage, under sādhana / vāṇī.
*
* Signed-in readers only, like quizzes themselves. Renders nothing until
* there is at least one published quiz; with
* `pendingOnly` it also hides once every quiz has been finished, so the
* homepage only nudges when there's something left to do.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUpRight, ChevronRight, ClipboardCheck } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { quiz, type QuizCard } from "@/lib/quiz";
import { cn } from "@/lib/utils";
export function QuizzesCTA({
variant = "banner",
pendingOnly = false,
className,
}: {
variant?: "banner" | "tile";
pendingOnly?: boolean;
className?: string;
}) {
const { user } = useAuth();
const userId = user?.id;
const [cards, setCards] = useState<QuizCard[] | null>(null);
useEffect(() => {
if (!userId) return;
let cancelled = false;
quiz
.list()
.then((c) => {
if (!cancelled) setCards(c);
})
.catch(() => {
/* CTA simply doesn't render */
});
return () => {
cancelled = true;
};
// Refetch on sign-in/out: progress is per user.
}, [userId]);
// Cards left over from before sign-out are ignored rather than cleared.
if (!user || !cards?.length) return null;
if (pendingOnly && cards.every((c) => c.my.state === "completed")) {
return null;
}
const tagline = "Test your understanding of chapters, lectures and more";
if (variant === "tile") {
// Matches the search page's FeaturedTile so it sits in that grid as an
// equal: same padding, icon chip, title scale and corner arrow.
return (
<Link
href="/quizzes"
className={cn(
"group relative flex flex-col h-full rounded-2xl bg-card ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all p-6",
className,
)}
>
<div className="flex items-start justify-between">
<div className="flex items-center justify-center size-11 rounded-xl bg-primary/12 text-primary">
<ClipboardCheck className="size-5" />
</div>
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
</div>
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] lg:text-[1.35rem] leading-tight text-foreground">
Quizzes
</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed flex-1">
{tagline}
</p>
</Link>
);
}
return (
<Link
href="/quizzes"
className={cn(
"group flex items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40",
className,
)}
>
<span className="inline-flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<ClipboardCheck className="size-6" />
</span>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold leading-snug text-foreground">
Quizzes
</p>
<p className="truncate text-sm leading-snug text-muted-foreground">
{tagline}
</p>
</div>
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
</Link>
);
}
+3 -1
View File
@@ -64,7 +64,9 @@ export function RewardCelebration({
You&apos;ve earned a reward!
</h2>
<p className="mt-1 text-sm text-muted-foreground">
For {grant.milestone} days of unbroken sādhana 🙏
{grant.milestone !== null
? `For ${grant.milestone} days of unbroken sādhana 🙏`
: "A gift for you 🙏"}
</p>
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
+63 -8
View File
@@ -15,9 +15,11 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { Gift } from "lucide-react";
import type { SadhnaRewardGrant } from "@/lib/sadhna";
import { Gift, Sparkles } from "lucide-react";
import { sadhna, type SadhnaRewardGrant } from "@/lib/sadhna";
import { useMounted } from "@/lib/use-mounted";
import { RewardCelebration } from "@/components/sadhna/reward-celebration";
import { cn } from "@/lib/utils";
export function RewardsGallery({
collected,
@@ -27,6 +29,11 @@ export function RewardsGallery({
nextRewardDate: string | null;
}) {
const [detail, setDetail] = useState<SadhnaRewardGrant | null>(null);
// The gift currently being opened — shown enlarged, over confetti.
const [unwrapping, setUnwrapping] = useState<SadhnaRewardGrant | null>(null);
// Gifts opened in this session. Held locally so the tile clears the moment
// the celebration is dismissed, rather than waiting for a page refresh.
const [justOpened, setJustOpened] = useState<Set<string>>(new Set());
// De-dupe by reward (collected is newest-first, so the first wins).
const seen = new Set<string>();
@@ -38,6 +45,19 @@ export function RewardsGallery({
}
}
// A gift stays wrapped until opened. Everything else — every streak
// reward — is simply collected.
const isWrapped = (g: SadhnaRewardGrant) =>
g.source === "gift" && !g.opened && !justOpened.has(g.reward.key);
const openGift = (g: SadhnaRewardGrant) => {
setUnwrapping(null);
setJustOpened((prev) => new Set(prev).add(g.reward.key));
// The tile has already cleared; a failed write only means it arrives
// wrapped again next load, which is better than blocking the celebration.
void sadhna.openGift(g.reward.key).catch(() => {});
};
return (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="mb-3 flex items-center gap-2">
@@ -61,20 +81,42 @@ export function RewardsGallery({
<button
key={g.reward.key}
type="button"
onClick={() => setDetail(g)}
className="group flex flex-col items-center gap-1.5 rounded-xl border border-border bg-background p-2 text-center transition-colors hover:border-primary/40 hover:bg-muted/40"
onClick={() =>
isWrapped(g) ? setUnwrapping(g) : setDetail(g)
}
aria-label={
isWrapped(g)
? "A gift is waiting — tap to open"
: g.reward.name
}
className={cn(
"group flex flex-col items-center gap-1.5 rounded-xl border bg-background p-2 text-center transition-colors",
isWrapped(g)
? "border-primary/40 hover:border-primary"
: "border-border hover:border-primary/40 hover:bg-muted/40",
)}
>
<div className="relative aspect-square w-full">
<Image
src={g.reward.image_url}
alt={g.reward.name}
alt={isWrapped(g) ? "" : g.reward.name}
fill
sizes="(max-width: 640px) 30vw, 120px"
className="object-contain p-1"
className={cn(
"object-contain p-1 transition-all",
// Blurred, not hidden: the shape hints at what's coming
// without giving it away.
isWrapped(g) && "scale-95 blur-md",
)}
/>
{isWrapped(g) && (
<span className="absolute inset-0 flex items-center justify-center">
<Sparkles className="size-6 text-primary drop-shadow" />
</span>
)}
</div>
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
{shortName(g.reward.name)}
{isWrapped(g) ? "A gift for you" : shortName(g.reward.name)}
</span>
</button>
))}
@@ -93,6 +135,15 @@ export function RewardsGallery({
{detail ? (
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
) : null}
{/* Reuses the streak celebration wholesale — a gift should feel like
any other sacred reward arriving, not like a second mechanism. */}
{unwrapping ? (
<RewardCelebration
grant={unwrapping}
onClose={() => openGift(unwrapping)}
/>
) : null}
</section>
);
}
@@ -145,7 +196,11 @@ function RewardDetail({
</p>
) : null}
<p className="mt-4 text-xs text-muted-foreground">
Earned on day {grant.milestone} of your streak
{/* A gift has no milestone — saying "day null of your streak"
would be worse than saying nothing about where it came from. */}
{grant.milestone !== null
? `Earned on day ${grant.milestone} of your streak`
: "An occasional gift"}
</p>
<button
type="button"
+40 -1
View File
@@ -8,13 +8,23 @@
*/
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
import { StreakFlame } from "@/components/sadhna/streak-flame";
export function SadhnaStreakCTA() {
export function SadhnaStreakCTA({
compact = false,
}: {
/**
* Icon-only tile for the homepage row beside Vāṇī of the Day. The streak
* and today's status move into the accessible label and tooltip, and the
* tile tints while a streak is running.
*/
compact?: boolean;
} = {}) {
const { user, loading } = useAuth();
const authed = !loading && !!user;
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
@@ -55,6 +65,35 @@ export function SadhnaStreakCTA() {
? "One offering done — finish today"
: "Chant and post a quote to complete today";
if (compact) {
const active = streak > 0;
return (
<Link
href="/sadhna-streak"
aria-label={`${title}. ${subtitle}`}
title={`${title} — ${subtitle}`}
className={
// An explicit square matching the homepage row's h-24. Deriving the
// width from height (aspect-square + h-full) collapses to the icon in
// Safari, so both sides are fixed.
"group p-2 flex flex-col size-24 shrink-0 items-center justify-center rounded-2xl border transition-colors " +
(active
? "border-primary/30 bg-primary/10 hover:border-primary/50"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40")
}
>
<Image
src="/icons/chanting-beads.svg"
alt=""
width={44}
height={44}
className="size-12 transition-transform group-hover:scale-105"
unoptimized
/>
</Link>
);
}
return (
<Link
href="/sadhna-streak"
@@ -382,7 +382,10 @@ export function PostCommentsDrawer({
rows={1}
maxLength={2000}
placeholder={replyTo ? "Write a reply…" : "Add a comment…"}
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-base sm:text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32"
// 16px wherever the pointer is coarse, so iOS Safari doesn't
// auto-zoom on focus. Keyed off pointer type rather than width:
// an iPad is wider than `sm` but still zooms a 14px field.
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm pointer-coarse:text-base text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32"
/>
<button
type="button"
+34 -6
View File
@@ -39,6 +39,8 @@ import {
type Comment,
type Post,
} from "@/lib/social";
import { useLongPress } from "@/lib/use-long-press";
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
@@ -62,6 +64,9 @@ export function PostActionsFooter({ post }: { post: Post }) {
// on mobile native-share; sheet mount on desktop) so the Send icon
// can render a spinner while the user waits.
const [sharePending, setSharePending] = useState(false);
// Opens the chat picker for this post. The post travels as a reference,
// not as text — the BFF renders the card and re-checks who may see it.
const [sharingToChat, setSharingToChat] = useState(false);
// Optimistic local mirror of post.viewer_has_reshared so the swap
// happens the instant the user taps Reshare. Reconciles to the
// server's value via rollback if the API call fails.
@@ -140,7 +145,16 @@ export function PostActionsFooter({ post }: { post: Post }) {
// parallel, then navigator.share) and the desktop/no-native-share
// path (mount the custom SharePostSheet). Provider's openShareSheet
// is always-resolves, so a single try/finally toggles the spinner.
async function onSendClick() {
// Tap → send to a chat. Hold → the platform's own share sheet, for
// everything outside the app. The link text is identical either way; only
// the destination differs.
function onSendClick() {
// No URL to resolve: sharing in-app sends the post id, so this is
// instant where the native sheet has to await the shortener.
setSharingToChat(true);
}
async function onSendHold() {
if (sharePending) return;
setSharePending(true);
try {
@@ -150,6 +164,12 @@ export function PostActionsFooter({ post }: { post: Post }) {
}
}
const sendGestures = useLongPress({
onClick: onSendClick,
onLongPress: onSendHold,
disabled: sharePending,
});
return (
<>
<div className="flex items-center justify-between px-2">
@@ -197,8 +217,8 @@ export function PostActionsFooter({ post }: { post: Post }) {
{showSend ? (
<ActionButton
label={sharePending ? "Preparing share link…" : "Send"}
onClick={onSendClick}
disabled={sharePending}
{...sendGestures}
icon={
sharePending ? (
<Loader2
@@ -270,29 +290,37 @@ export function PostActionsFooter({ post }: { post: Post }) {
{/* SharePostSheet is mounted globally by SharePostProvider, so
no per-card instance lives here. The provider's
openShareSheet swaps the active post under the hood. */}
{sharingToChat && (
<ShareToChatDialog
entity={{ type: "post", id: post.id }}
onClose={() => setSharingToChat(false)}
/>
)}
</>
);
}
function ActionButton({
label,
onClick,
icon,
disabled = false,
...buttonProps
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
/** Greys out the button and blocks clicks. Used by the Send button
* while the short URL is being resolved on the mobile native-share
* path so users can't fire navigator.share before the URL is
* ready. */
disabled?: boolean;
}) {
/** onClick, plus the pointer handlers the Send button needs to tell a
* tap from a hold. */
} & React.ComponentProps<"button">) {
return (
<button
{...buttonProps}
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
+8 -22
View File
@@ -38,8 +38,7 @@ import {
useState,
} from "react";
import type { Post } from "@/lib/social";
import { createShortLink } from "@/lib/share";
import { structuredReferenceForPost } from "@/lib/source-reference";
import { buildPostShareText } from "@/lib/post-share-text";
import { useAuth } from "@/components/auth/auth-provider";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { SharePostSheet } from "./share-post-sheet";
@@ -131,30 +130,17 @@ export function SharePostProvider({
// it.
// 3. On any non-AbortError, fall back to the custom sheet.
try {
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
const hasImage = !!post.images?.[0];
const [code, imageFile] = await Promise.all([
createShortLink(longPath),
const [share, imageFile] = await Promise.all([
buildPostShareText(post, handle),
hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null),
]);
const origin = window.location.origin;
const url = code
? new URL(`/share/${code}`, origin).toString()
: new URL(longPath, origin).toString();
if (!share) {
setActivePost(post);
return;
}
const ref = structuredReferenceForPost(
post.source_url,
post.source_title,
);
const lines = [`“${post.caption}”`, "", "Source:", ref.title];
if (ref.detail) lines.push(ref.detail);
lines.push(url);
const text = lines.join("\n");
const title = ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title;
const payload: ShareData = { title, text };
const payload: ShareData = { title: share.title, text: share.text };
// Only attach files when the platform actually accepts them.
// canShare returns false on Chrome desktop / Firefox even
// though navigator.share itself exists, and we want to fail
+54 -13
View File
@@ -6,16 +6,16 @@ import {
Copy,
FolderPlus,
Link as LinkIcon,
MessageCircle,
MoreHorizontal,
Send,
Share2,
} from "lucide-react";
import { toast } from "sonner";
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
import { buildHighlightUrl } from "@/lib/text-highlight";
import { buildHighlightPath, buildHighlightUrl } from "@/lib/text-highlight";
import { bookmarks, SocialError } from "@/lib/social";
import {
KautilyaIcon,
useMediaQuery,
useCopyFeedback,
type Position,
@@ -25,6 +25,8 @@ import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
import { ComposePost } from "./selection-toolbar/compose-post";
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
import type { QuotePayload } from "@/lib/chat";
import { useAuth } from "./auth/auth-provider";
import { useAuthGate } from "./auth/auth-gate";
@@ -34,6 +36,8 @@ import { useAuthGate } from "./auth/auth-gate";
export function TextSelectionToolbar() {
const [selectedText, setSelectedText] = useState("");
// The passage to share, captured at click time. Non-null opens the dialog.
const [chatQuote, setChatQuote] = useState<QuotePayload | null>(null);
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
const [showMore, setShowMore] = useState(false);
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
@@ -327,7 +331,20 @@ export function TextSelectionToolbar() {
return null;
}
if (!toolbarPos || !selectedText) return null;
// The share dialog deliberately OUTLIVES the selection that opened it:
// onChat snapshots the passage and then calls finishAction, which clears
// selectedText. Rendered only below the guard, it would never mount — the
// component returns null on that same render, and the dialog would appear
// on the next selection instead, when selectedText is non-empty again.
//
// The other surfaces avoid this by using saveAndClearSelection, which keeps
// selectedText in state; this one lets the selection go entirely.
const shareToChat =
chatQuote !== null ? (
<ShareToChatDialog quote={chatQuote} onClose={() => setChatQuote(null)} />
) : null;
if (!toolbarPos || !selectedText) return shareToChat;
// ─── Action handlers ──────────────────────────────────────────────────
@@ -337,6 +354,21 @@ export function TextSelectionToolbar() {
setShowCollections(true);
}
async function onChat() {
// Snapshot the passage first: requireAuth may open a login flow, and
// finishAction clears the selection — the dialog outlives both.
const ref = getSourceReference();
const quote: QuotePayload = {
// Relative, not absolute: the BFF only accepts in-app paths for quotes.
path: buildHighlightPath(selectedText),
title: ref ? (ref.detail ? `${ref.title} — ${ref.detail}` : ref.title) : "",
text: selectedText,
};
if (!(await requireAuth())) return;
setChatQuote(quote);
finishAction();
}
async function onPost() {
if (!(await requireAuth())) return;
if (isMobile) saveAndClearSelection();
@@ -418,14 +450,11 @@ export function TextSelectionToolbar() {
const isAuthed = !!user;
const askAction: ToolbarAction = {
key: "ask",
label: "Ask Kautilya",
icon: <KautilyaIcon className="size-3.5" />,
onClick: () => {
// TODO: Implement Ask Kautilya
finishAction();
},
const chatAction: ToolbarAction = {
key: "chat",
label: "Chat",
icon: <MessageCircle className="size-3.5" />,
onClick: onChat,
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
};
@@ -445,7 +474,7 @@ export function TextSelectionToolbar() {
};
const authedToolbarActions: ToolbarAction[] = [
askAction,
chatAction,
{
key: "post",
label: "Post",
@@ -466,7 +495,6 @@ export function TextSelectionToolbar() {
];
const publicToolbarActions: ToolbarAction[] = [
askAction,
{
key: "copy",
label: "Copy",
@@ -515,6 +543,17 @@ export function TextSelectionToolbar() {
];
const publicMoreItems: MoreItem[] = [
{
key: "chat-public",
label: "Chat",
description: "Send this passage to someone",
icon: <MessageCircle className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
await onChat();
},
},
{
key: "share-as-post",
label: "Share as Post",
@@ -601,6 +640,8 @@ export function TextSelectionToolbar() {
isMobile={isMobile}
selectedText={selectedText}
/>
{shareToChat}
</div>
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
// A curated, broadly-useful set — enough to feel like a picker without
// shipping a full emoji database. Grouped for easy scanning.
const EMOJI_GROUPS: { label: string; emojis: string[] }[] = [
export const EMOJI_GROUPS: { label: string; emojis: string[] }[] = [
{
label: "Smileys",
emojis: [
+6 -5
View File
@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { getVaniOfTheDay, type Vani } from "@/lib/vani";
import { cn } from "@/lib/utils";
/** Quote-marks glyph (svgrepo). Inherits color via `currentColor`. */
function QuotesIcon({ className }: { className?: string }) {
@@ -28,7 +29,7 @@ function QuotesIcon({ className }: { className?: string }) {
);
}
export function VaniOfTheDayCTA() {
export function VaniOfTheDayCTA({ className }: { className?: string } = {}) {
const [vani, setVani] = useState<Vani | null>(null);
useEffect(() => {
@@ -57,11 +58,11 @@ export function VaniOfTheDayCTA() {
return (
<Link
href={vani.href}
className="group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
className={cn(
"group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40",
className,
)}
>
<span className="inline-flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<QuotesIcon className="size-6" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-primary">Vani of the day</p>
<p className="mt-0.5 truncate text-base font-semibold text-foreground leading-snug">
+51
View File
@@ -0,0 +1,51 @@
/**
* Browser-side client for admin announcements.
*
* Deliberately thin: an announcement is fetched, shown once, and dismissed.
* Nothing is cached client-side — "have I seen this?" is the server's answer,
* so it survives a new device, a cleared browser, and a second tab.
*/
import type { MessageEntity } from "@/lib/chat";
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
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,
});
if (!res.ok) throw new Error(`broadcast request failed: ${res.status}`);
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as {
data?: T;
success?: boolean;
};
// NOT `body.data ?? body`: /pending answers {data: null} when nothing is
// waiting, and `??` would fall through and hand back the whole envelope —
// which is truthy, so the modal would try to render it as an announcement.
return (body.data ?? (null as unknown as T)) as T;
}
export type Broadcast = {
id: string;
title: string;
body: string;
/** Formatting spans over `body`, same shape chat messages use. */
entities: MessageEntity[];
banner_url?: string | null;
cta_label?: string | null;
cta_url?: string | null;
published_at?: string | null;
expires_at?: string | null;
};
export const broadcasts = {
/** The announcement waiting for this user, or null. */
pending: () => call<Broadcast | null>("/broadcasts/pending"),
/** Dismiss it. Idempotent, so a double-tap or two tabs are both fine. */
markSeen: (id: string) =>
call<void>(`/broadcasts/${id}/seen`, { method: "POST" }),
};
+6 -1
View File
@@ -29,10 +29,15 @@ export function buildEntityTree(
body: string,
entities: MessageEntity[],
): EntityNode[] {
// Defensive on both inputs: this renders content from the network, and a
// missing body should show nothing rather than take down the screen it is
// on. The broadcast modal renders through here too, and it mounts on every
// page — so a crash here is not confined to chat.
const text = body ?? "";
const sorted = [...(entities ?? [])].sort(
(a, b) => a.offset - b.offset || b.length - a.length,
);
return build(body, 0, body.length, sorted);
return build(text, 0, text.length, sorted);
}
function build(
+134 -1
View File
@@ -119,6 +119,8 @@ export type ChatMessage = {
reply_to?: ReplyPreview | null;
/** Always an array — media, link cards and shared entities. */
attachments: Attachment[];
/** Emoji chips, grouped with counts. `mine` marks the viewer's own. */
reactions: Reaction[];
/**
* Client-only. Set on a message shown optimistically before the server has
* acknowledged it; never sent by the BFF.
@@ -168,7 +170,33 @@ export type AttachmentKind =
| "audio"
| "file"
| "link"
| "entity";
| "entity"
| "quote"
| "sticker";
/** One emoji on a message, grouped across everyone who chose it. */
export type Reaction = {
emoji: string;
count: number;
/** The viewer's own choice — highlight the chip, and tapping it removes. */
mine: boolean;
};
/** A piece of reward artwork the caller may send. */
export type Sticker = {
key: string;
name: string;
image_url: string;
/** Came from a chat rather than from sādhana. */
saved: boolean;
};
/** One person's reaction, for the "who reacted" list. */
export type ReactionDetail = {
user: ChatPeer;
emoji: string;
created_at: string;
};
export type Attachment = {
id: string;
@@ -187,6 +215,25 @@ export type Attachment = {
description?: string | null;
site_name?: string | null;
image_url?: string | null;
/** Shared entity. `entity` is resolved per viewer on every read, so it can
* be absent even though the message still references a post. */
entity_type?: "post" | null;
entity_id?: string | null;
entity?: SharedPost | null;
/** The post was deleted, hidden, or is no longer visible to this viewer. */
entity_unavailable?: boolean;
};
/** The post card the BFF resolves for a shared-post attachment. */
export type SharedPost = {
id: string;
author_handle: string;
author_name: string;
author_avatar_url?: string | null;
caption: string;
image_url?: string | null;
source_title?: string;
};
/** How two people in a DM stand with each other. */
@@ -231,6 +278,10 @@ export const chat = {
* Opens (or returns) the DM with a profile. Idempotent, and keyed by handle
* because that is what a profile page has.
*/
/** Threads waiting on the caller. Muted ones are excluded — see the BFF. */
unreadCount: () =>
call<{ count: number }>("/chat/unread").then((r) => r.count),
openDM: (handle: string) =>
call<{ id: string; kind: ConversationKind }>("/chat/conversations/dm", {
method: "POST",
@@ -259,6 +310,11 @@ export const chat = {
replyToId?: string;
previewUrl?: string;
attachments?: UploadedAttachment[];
quote?: QuotePayload;
/** Share a post as a card. Only the reference — the BFF resolves it. */
entity?: { type: "post"; id: string };
/** Send reward artwork. Ownership is checked server-side. */
stickerKey?: string;
},
) =>
call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, {
@@ -270,6 +326,9 @@ export const chat = {
// Only the URL: the BFF fetches the card's contents itself.
preview_url: opts?.previewUrl ?? null,
attachments: opts?.attachments ?? [],
quote: opts?.quote ?? null,
entity: opts?.entity ?? null,
sticker_key: opts?.stickerKey ?? null,
}),
}),
@@ -382,6 +441,52 @@ export const chat = {
body: JSON.stringify({ seq }),
}),
/** One hydrated message — reaction counts are per viewer, so a client told
* "message X changed" has to ask rather than compute. */
message: (conversationId: string, messageId: string) =>
call<ChatMessage>(
`/chat/conversations/${conversationId}/messages/${messageId}`,
),
/** Tell the thread someone is composing. Ephemeral — nothing is stored. */
typing: (conversationId: string) =>
call<void>(`/chat/conversations/${conversationId}/typing`, {
method: "POST",
}),
/** Everything the caller can send: earned through sādhana, or saved from
* a chat. */
stickers: () => call<Sticker[]>("/chat/stickers"),
/** Keep a sticker someone sent. Keyed to the message it arrived on, which
* is what proves it was actually sent to you. */
saveSticker: (conversationId: string, messageId: string, key: string) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/sticker`,
{ method: "POST", body: JSON.stringify({ key }) },
),
/** Who reacted, and with what. Only fetched when the list is opened —
* the thread renders from the grouped counts on each message. */
reactionDetails: (conversationId: string, messageId: string) =>
call<ReactionDetail[]>(
`/chat/conversations/${conversationId}/messages/${messageId}/reactions`,
),
/** Set the caller's reaction, replacing any previous one. */
react: (conversationId: string, messageId: string, emoji: string) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/reaction`,
{ method: "PUT", body: JSON.stringify({ emoji }) },
),
/** Remove the caller's reaction. Idempotent. */
unreact: (conversationId: string, messageId: string) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/reaction`,
{ method: "DELETE" },
),
markDelivered: (conversationId: string, seq: number) =>
call<void>(`/chat/conversations/${conversationId}/delivered`, {
method: "POST",
@@ -399,6 +504,19 @@ export const chat = {
};
/** A file already uploaded to the private bucket, ready to attach on send. */
/**
* A passage shared from the library.
*
* `path` is app-relative on purpose — the BFF refuses anything absolute or
* protocol-relative, because a quote renders as trusted in-app content and
* must not be able to carry the reader off-site.
*/
export type QuotePayload = {
path: string;
title: string;
text: string;
};
export type UploadedAttachment = {
key: string;
mime: string;
@@ -551,6 +669,21 @@ export function followSummary(
}
/** Resolves a sender id to a display name using a thread's participants. */
/**
* The participant who sent a message, or undefined for Kautilya, a system
* message, or someone who has since been removed.
*
* Groups need this rather than the conversation's `peer`: `peer` is only set
* on DMs, so a group has no single counterpart to read an avatar from.
*/
export function senderPeer(
senderId: string | null | undefined,
participants: ChatPeer[],
): ChatPeer | undefined {
if (!senderId) return undefined;
return participants.find((p) => p.id === senderId);
}
export function senderName(
senderId: string | null | undefined,
participants: ChatPeer[],
+62
View File
@@ -318,6 +318,68 @@ export function ogForLettersByLocation(location: string, count: number) {
});
}
// ---------------------------------------------------------------------------
// Quizzes
// ---------------------------------------------------------------------------
const QUIZ_DESCRIPTION =
"Test your understanding of Śrīla Prabhupāda's books, lectures and conversations.";
const QUIZ_KIND_LABEL: Record<string, string> = {
chapter: "Book chapter",
lecture: "Lecture",
conversation: "Conversation",
};
/**
* Admin text carries light formatting markers (*bold*, _italic_, links…).
* A preview card wants the words only.
*/
function stripRichMarkers(text: string): string {
return text
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1")
.replace(/__([^_\n]+?)__/g, "$1")
.replace(/\*([^*\n]+?)\*/g, "$1")
.replace(/~([^~\n]+?)~/g, "$1")
.replace(/(^|\W)_([^_\n]+?)_(?=\W|$)/g, "$1$2")
.replace(/\s+/g, " ")
.trim();
}
/** OG metadata for a single quiz. */
export function ogForQuiz(quiz: {
title: string;
description?: string;
target_kind: string;
target_title?: string;
question_count: number;
}) {
const kind = QUIZ_KIND_LABEL[quiz.target_kind];
const count = `${quiz.question_count} question${quiz.question_count === 1 ? "" : "s"}`;
const detail = [
"Quiz",
kind && quiz.target_title ? `${kind}: ${quiz.target_title}` : null,
count,
]
.filter(Boolean)
.join(" · ");
const about = stripRichMarkers(quiz.description ?? "");
return buildOgMeta({
title: quiz.title,
detail,
quote: about || QUIZ_DESCRIPTION,
});
}
/** OG metadata for the /quizzes index. */
export function ogForQuizzes() {
return buildOgMeta({
title: "Quizzes",
detail: "Prabhupāda Says",
quote: QUIZ_DESCRIPTION,
});
}
// ---------------------------------------------------------------------------
// hl= query param → OG quote extraction
// ---------------------------------------------------------------------------
+53
View File
@@ -0,0 +1,53 @@
/**
* Builds the shareable text for a post — the caption, its source reference,
* and a link.
*
* Extracted so the native share sheet and "send to a chat" quote a post
* identically. They differ only in where the text ends up, and two copies of
* this would drift the moment either format changed.
*/
import { createShortLink } from "@/lib/share";
import { structuredReferenceForPost } from "@/lib/source-reference";
import type { Post } from "@/lib/social";
export type PostShareText = {
/** Short link when the shortener answered, canonical URL otherwise. */
url: string;
/** Source reference on one line, for a native share sheet's title slot. */
title: string;
/** Caption, source reference and link — the body of the share. */
text: string;
};
/**
* `viewerHandle` covers the case where the BFF's create response omits
* author_handle: the poster IS the signed-in user right after posting.
* Returns null when neither yields a handle, since there is no post URL
* without one.
*/
export async function buildPostShareText(
post: Post,
viewerHandle: string | undefined,
): Promise<PostShareText | null> {
const handle = post.author_handle ?? viewerHandle ?? "";
if (!handle) return null;
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
const code = await createShortLink(longPath);
const origin = window.location.origin;
const url = code
? new URL(`/share/${code}`, origin).toString()
: new URL(longPath, origin).toString();
const ref = structuredReferenceForPost(post.source_url, post.source_title);
const lines = [`“${post.caption}”`, "", "Source:", ref.title];
if (ref.detail) lines.push(ref.detail);
lines.push(url);
return {
url,
title: ref.detail ? `${ref.title} — ${ref.detail}` : ref.title,
text: lines.join("\n"),
};
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Groups the quiz list for the "All" tab:
*
* Books → one group per book, in the library's order
* Lectures & Conversations → one group per year, oldest first, undated last
* Other quizzes → stand-alone quizzes
*
* Only groups with quizzes in them are returned, so an empty category or
* sub-category never renders.
*/
import { BOOK_NAMES } from "@/lib/constants";
import type { QuizCard } from "@/lib/quiz";
export type QuizGroup = { key: string; label: string; quizzes: QuizCard[] };
export type QuizSections = {
books: QuizGroup[];
lectures: QuizGroup[];
other: QuizCard[];
};
const BOOK_ORDER = Object.keys(BOOK_NAMES);
/** "bg" from /books/bg/2 — the book a chapter quiz belongs to. */
function bookCode(path?: string | null): string | null {
const m = path?.match(/^\/books\/([^/]+)/);
return m ? m[1] : null;
}
/**
* The recording's year. Saved on the quiz since target_year existed; for
* older quizzes, read from the slug's yymmdd prefix (720720sb-london → 1972)
* when it falls in Śrīla Prabhupāda's recorded years.
*/
function lectureYear(q: QuizCard): number | null {
if (q.target_year) return q.target_year;
const m = q.target_path?.match(/^\/transcriptions\/(\d{2})\d{4}/);
if (!m) return null;
const y = 1900 + Number(m[1]);
return y >= 1966 && y <= 1977 ? y : null;
}
export function groupQuizzes(cards: QuizCard[]): QuizSections {
const books = new Map<string, QuizCard[]>();
const years = new Map<number | null, QuizCard[]>();
const other: QuizCard[] = [];
for (const q of cards) {
if (q.target_kind === "chapter") {
const code = bookCode(q.target_path) ?? "other";
books.set(code, [...(books.get(code) ?? []), q]);
} else if (
q.target_kind === "lecture" ||
q.target_kind === "conversation"
) {
const y = lectureYear(q);
years.set(y, [...(years.get(y) ?? []), q]);
} else {
other.push(q);
}
}
const rank = (code: string) => {
const i = BOOK_ORDER.indexOf(code);
return i < 0 ? BOOK_ORDER.length : i;
};
return {
books: [...books.entries()]
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.map(([code, quizzes]) => ({
key: code,
label: BOOK_NAMES[code] ?? code.toUpperCase(),
// Chapter order (/books/bg/2 before /books/bg/10), not newest first.
quizzes: [...quizzes].sort((a, b) =>
(a.target_path ?? "").localeCompare(b.target_path ?? "", undefined, {
numeric: true,
}),
),
})),
lectures: [...years.entries()]
// Oldest first; undated (null) last.
.sort(([a], [b]) => (a ?? 9999) - (b ?? 9999))
.map(([year, quizzes]) => ({
key: String(year ?? "undated"),
label: year ? String(year) : "Undated",
quizzes,
})),
other,
};
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Browser-side client for quizzes.
*
* Correct answers never come down while an attempt is in progress — the BFF
* withholds them — so nothing here can reveal one early. Results are only
* ever the caller's own: the BFF reads the user from the session.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class QuizError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
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,
});
let json: { data?: T; error?: { message?: string } } | null = null;
try {
json = await res.json();
} catch {
/* empty body */
}
if (!res.ok) {
throw new QuizError(
res.status,
json?.error?.message || "Quiz request failed",
);
}
// `data` can legitimately be null (no quiz on this page), so test for the
// key rather than falling back with `??`, which would return the envelope.
return (json && "data" in json ? json.data : json) as T;
}
export type QuizState = "not_started" | "in_progress" | "completed";
export type QuizOption = {
id: string;
ord: number;
body: string;
is_correct?: boolean;
};
export type QuizQuestion = {
id: string;
ord: number;
prompt: string;
explanation?: string;
/** "Select all that apply": several options may be correct. */
multi: boolean;
options: QuizOption[];
};
export type QuizCard = {
id: string;
title: string;
description: string;
target_kind: "chapter" | "lecture" | "conversation" | "standalone";
target_path?: string | null;
target_title: string;
/** Year of the lecture / conversation, when known. */
target_year?: number | null;
question_count: number;
my: { state: QuizState; answered: number; total: number; score?: number };
};
export type QuizPlay = {
quiz_id: string;
title: string;
answered: number;
total: number;
done: boolean;
question?: QuizQuestion;
};
export type QuizResult = {
quiz: QuizCard;
score: number;
total: number;
started_at: string;
completed_at?: string;
total_ms: number;
items: (QuizQuestion & {
chosen_option_ids: string[];
is_correct: boolean;
response_ms?: number;
})[];
};
export const quiz = {
/** The quiz on a content page, or null when it has none. */
forPath: (path: string) =>
call<QuizCard | null>(`/quizzes/for?path=${encodeURIComponent(path)}`),
list: () => call<QuizCard[]>("/quizzes"),
get: (id: string) => call<QuizCard>(`/quizzes/${id}`),
/** Opens or resumes your attempt and returns the current question. */
start: (id: string) =>
call<QuizPlay>(`/quizzes/${id}/start`, { method: "POST" }),
/** One option, or several for a multi question. Final once sent. */
answer: (id: string, questionId: string, optionIds: string[]) =>
call<QuizPlay>(`/quizzes/${id}/answer`, {
method: "POST",
body: JSON.stringify({ question_id: questionId, option_ids: optionIds }),
}),
result: (id: string) => call<QuizResult>(`/quizzes/${id}/result`),
};
/** The button's wording follows where you are in the quiz. */
export function quizActionLabel(state: QuizState): string {
return state === "completed"
? "View results"
: state === "in_progress"
? "Continue quiz"
: "Take quiz";
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Renders the light formatting the admin message fields allow:
*
* *bold* _italic_ __underline__ ~strike~ [label](https://…)
*
* plus bare http(s) URLs. The rules mirror pkg/richtext in the BFF, which
* renders the same markers into email — so the preview shown here is what
* the recipient's inbox gets.
*
* Output is React nodes, never an HTML string: React escapes all text, so
* author input cannot become markup, and links are limited to http(s).
*
* NOTE: a copy of spsays-admin/lib/rich-text.tsx. The two are separate Next
* apps with no shared package — the same arrangement as compress-image.ts.
* Change both, and pkg/richtext in the BFF, together.
*/
import type { ReactNode } from "react";
const LINK_RE =
/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+)/g;
const TRAILING_PUNCT = /[.,;:!?)]+$/;
type Marker = { re: RegExp; wrap: (n: ReactNode, k: string) => ReactNode };
// Order matters: __underline__ before _italic_, so the double underscore
// isn't read as two italic markers.
const MARKERS: Marker[] = [
{ re: /__([^_\n]+?)__/, wrap: (n, k) => <u key={k}>{n}</u> },
{ re: /\*([^*\n]+?)\*/, wrap: (n, k) => <strong key={k}>{n}</strong> },
{ re: /~([^~\n]+?)~/, wrap: (n, k) => <s key={k}>{n}</s> },
{ re: /_([^_\n]+?)_/, wrap: (n, k) => <em key={k}>{n}</em> },
];
const isWord = (c: string | undefined) => !!c && /\w/.test(c);
/** Markers within a run of text that contains no links. */
function renderMarkers(text: string, keyBase: string): ReactNode[] {
// Earliest match across all markers wins, so nesting resolves outside-in.
let best: { idx: number; len: number; inner: string; m: Marker } | null =
null;
for (const m of MARKERS) {
const re = new RegExp(m.re.source, "g");
let hit: RegExpExecArray | null;
while ((hit = re.exec(text))) {
// Italic needs a non-word character either side, so snake_case and
// handles keep their underscores.
if (m.re.source.startsWith("_(")) {
const before = text[hit.index - 1];
const after = text[hit.index + hit[0].length];
if (isWord(before) || isWord(after)) continue;
}
if (!best || hit.index < best.idx) {
best = { idx: hit.index, len: hit[0].length, inner: hit[1], m };
}
break;
}
}
if (!best) return text ? [text] : [];
const before = text.slice(0, best.idx);
const after = text.slice(best.idx + best.len);
const k = `${keyBase}-${best.idx}`;
return [
...(before ? [before] : []),
best.m.wrap(renderMarkers(best.inner, `${k}i`), k),
...renderMarkers(after, `${k}a`),
];
}
/** Renders formatted text, newlines included. */
export function RichText({ text }: { text: string }) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
return (
<>
{lines.map((line, li) => {
const out: ReactNode[] = [];
let last = 0;
for (const m of line.matchAll(LINK_RE)) {
const idx = m.index ?? 0;
out.push(...renderMarkers(line.slice(last, idx), `${li}-${last}`));
let href = m[2] ?? m[3];
let label = m[1] ?? href;
let tail = "";
if (!m[1]) {
// Sentence punctuation after a bare URL isn't part of it.
tail = href.match(TRAILING_PUNCT)?.[0] ?? "";
href = href.slice(0, href.length - tail.length);
label = href;
}
out.push(
<a
key={`${li}-l${idx}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary underline-offset-2 hover:underline"
>
{label}
</a>,
);
if (tail) out.push(tail);
last = idx + m[0].length;
}
out.push(...renderMarkers(line.slice(last), `${li}-${last}`));
return (
<span key={li}>
{li > 0 && <br />}
{out}
</span>
);
})}
</>
);
}
+11 -1
View File
@@ -111,9 +111,13 @@ export type SadhnaReward = {
/** A reward earned at a streak milestone (a multiple of 7 days). */
export type SadhnaRewardGrant = {
milestone: number;
/** The streak day this was earned on. Null for an occasional gift. */
milestone: number | null;
granted_at: string;
reward: SadhnaReward;
source: "streak" | "gift";
/** False while a gift is still wrapped — the gallery blurs those. */
opened: boolean;
};
// ─── API ───────────────────────────────────────────────────────────────
@@ -140,6 +144,12 @@ export const sadhna = {
rewards: () =>
call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"),
/** Open one gift, after its celebration is dismissed. Idempotent. */
openGift: (rewardKey: string) =>
call<void>(`/sadhna/rewards/gifts/${encodeURIComponent(rewardKey)}/open`, {
method: "POST",
}),
/** Acknowledge pending celebrations after showing the confetti. */
ackRewards: () => call<void>("/sadhna/rewards/ack", { method: "POST" }),
+18 -1
View File
@@ -212,6 +212,8 @@ export type Feedback = {
id: string;
user_id?: string;
category: string;
/** The category's current display name (renames apply to past feedback). */
category_label?: string;
message: string;
contact_email?: string;
created_at: string;
@@ -560,13 +562,25 @@ export const collections = {
// ─── Feedback ────────────────────────────────────────────────────────────
/** One option on the /feedback form, managed from the admin panel. */
export type FeedbackCategory = {
key: string;
label: string;
description: string;
};
export const feedback = {
/** The form's categories, in order. Public. */
categories() {
return call<FeedbackCategory[]>("/feedback/categories");
},
/** Submit a /feedback page entry. When the viewer is signed in the
* BFF captures their user_id automatically via the auth cookie —
* the form doesn't ask for it. `contact_email` is required only
* for anonymous submissions; the BFF re-enforces that on its end. */
submit(input: {
category: "bug" | "feature" | "general" | "other";
/** A FeedbackCategory key. */
category: string;
message: string;
contact_email?: string;
}) {
@@ -712,6 +726,9 @@ export const notifications = {
// ─── Notification preferences ──────────────────────────────────────────
export type PrefEvent =
/** Chat push. Delivered by the chat service directly, not the notification
* tray — a new message highlights its inbox row instead. */
| "messages"
| "follows"
| "post_interactions"
| "sadhna_reminders"
+12
View File
@@ -303,6 +303,18 @@ export function buildSearchResultUrl(
/**
* Build the full URL with highlight fragment for the selected text.
*/
/**
* The highlight link as an app-relative path ("/books/bg/2/13?hl=…").
*
* Chat quotes store a path rather than a full URL: the BFF refuses absolute
* links there, since a quote renders as trusted in-app content and must not be
* able to carry the reader off-site.
*/
export function buildHighlightPath(selectedText: string): string {
const url = new URL(buildHighlightUrl(selectedText));
return url.pathname + url.search + url.hash;
}
export function buildHighlightUrl(selectedText: string): string {
const fragment = encodeTextFragment(selectedText);
if (!fragment) return window.location.href;
+28
View File
@@ -0,0 +1,28 @@
"use client";
/**
* Whether the app's own chrome should step aside for the page.
*
* A chat thread is a full-height surface with its own header and a composer
* pinned to the bottom: the tab bar competes with the composer for the same
* edge, the hamburger duplicates a back button already on screen, and the
* wordmark header stacks a second title bar above the thread's own.
*
* One hook so the header, the nav, the hamburger and the layout's bottom
* padding can never disagree — if they did, hiding one would leave a gap
* where it used to be. The desktop sidebar is deliberately unaffected: it is
* the only way out of a thread on a wide screen.
*/
import { usePathname } from "next/navigation";
/**
* True on a thread itself (`/chats/<id>`) but NOT on the inbox or a chat's
* profile page: those are ordinary scrolling pages where the tab bar belongs.
*/
export function useHidesAppChrome(): boolean {
const pathname = usePathname();
if (!pathname?.startsWith("/chats/")) return false;
// "", "chats", "<id>" — anything deeper is a sub-page like /profile.
return pathname.split("/").filter(Boolean).length === 2;
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
/**
* Distinguishes a tap from a press-and-hold on the same control.
*
* Built on Pointer Events so one implementation covers touch, mouse and pen.
* Two details make it behave:
*
* - A hold that fires must swallow the click that follows it, or the tap
* action runs straight after the hold action.
* - Small finger movement during a hold is normal, so the press only
* cancels once it drifts past a slop threshold — otherwise holds fail
* constantly on touch.
*
* Timing and slop match the chat bubbles' gesture handler so a hold feels the
* same everywhere in the app.
*/
import { useCallback, useEffect, useRef } from "react";
const LONG_PRESS_MS = 450;
const LONG_PRESS_SLOP_PX = 8;
export function useLongPress({
onClick,
onLongPress,
disabled = false,
}: {
onClick: () => void;
onLongPress: () => void;
disabled?: boolean;
}) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const start = useRef<{ x: number; y: number } | null>(null);
const fired = useRef(false);
const clear = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
start.current = null;
}, []);
// A hold can end with the pointer outside the button, or with the component
// unmounting behind whatever the hold opened; either would leak the timer.
useEffect(() => clear, [clear]);
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return;
fired.current = false;
start.current = { x: e.clientX, y: e.clientY };
timer.current = setTimeout(() => {
fired.current = true;
clear();
onLongPress();
}, LONG_PRESS_MS);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!start.current) return;
const dx = e.clientX - start.current.x;
const dy = e.clientY - start.current.y;
if (Math.abs(dx) > LONG_PRESS_SLOP_PX || Math.abs(dy) > LONG_PRESS_SLOP_PX) {
clear();
}
};
return {
onPointerDown,
onPointerMove,
onPointerUp: clear,
onPointerCancel: clear,
onPointerLeave: clear,
// Suppress iOS's press-and-hold callout, which would otherwise cover the
// sheet the hold just opened.
onContextMenu: (e: React.MouseEvent) => {
if (fired.current) e.preventDefault();
},
onClick: () => {
// The hold already acted; the browser's trailing click is not a tap.
if (fired.current) {
fired.current = false;
return;
}
if (!disabled) onClick();
},
};
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
/**
* Tracks the *visual* viewport — the part of the page not covered by the
* software keyboard.
*
* Why this exists: on iOS Safari `100dvh` does not shrink when the keyboard
* opens. The dynamic viewport units respond to browser chrome collapsing, not
* to the keyboard, so a composer pinned to the bottom of a `100dvh` column
* ends up underneath it. `window.visualViewport` is the only thing iOS updates
* reliably, so the thread is sized from that instead while the keyboard is up.
*
* `offsetTop` matters as much as `height`: iOS scrolls the layout viewport to
* reveal the focused field rather than resizing it, so a panel pinned to the
* visible area has to follow that offset or it drifts off-screen.
*/
import { useEffect, useState } from "react";
export type VisualViewport = {
/** Visible height in px, excluding the keyboard. */
height: number;
/** How far the layout viewport has scrolled to reveal the focused field. */
offsetTop: number;
/** True once the visible area is materially shorter than the window. */
keyboardOpen: boolean;
};
/**
* A URL bar collapsing costs ~50–60px; a keyboard costs several hundred. The
* threshold keeps ordinary chrome changes from being read as a keyboard.
*/
const KEYBOARD_THRESHOLD_PX = 120;
export function useVisualViewport(): VisualViewport | null {
const [state, setState] = useState<VisualViewport | null>(null);
useEffect(() => {
const vv = window.visualViewport;
// Absent on older browsers — callers fall back to their CSS height.
if (!vv) return;
const apply = () => {
setState({
height: vv.height,
offsetTop: vv.offsetTop,
keyboardOpen: window.innerHeight - vv.height > KEYBOARD_THRESHOLD_PX,
});
};
apply();
vv.addEventListener("resize", apply);
// iOS fires scroll (not resize) as the layout viewport shifts under the
// keyboard, so both are needed to stay pinned.
vv.addEventListener("scroll", apply);
return () => {
vv.removeEventListener("resize", apply);
vv.removeEventListener("scroll", apply);
};
}, []);
return state;
}