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

Some files were not shown because too many files have changed in this diff Show More