Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f855699e72 | ||
|
|
52cd48b12e | ||
|
|
0a23ab59f7 | ||
|
|
3af4e1bcd3 | ||
|
|
fed12b0d46 | ||
|
|
d64485daa4 | ||
|
|
0968b6046d | ||
|
|
8dd72de359 | ||
|
|
8537eb6ce2 | ||
|
|
09dc734613 | ||
|
|
5832125ab2 | ||
|
|
0e2e0d7300 | ||
|
|
6a6143ceb2 | ||
|
|
9f67da37a6 | ||
|
|
cce8a4c05f | ||
|
|
b391e1c575 | ||
|
|
3ce08428c8 | ||
|
|
bf8db28a7c | ||
|
|
cc9ea45c61 | ||
|
|
7a9d362b15 | ||
|
|
c001eeab45 | ||
|
|
2b0f966f90 | ||
|
|
2597c6f2ec | ||
|
|
8c7afe596d | ||
|
|
0f382ed885 | ||
|
|
03c8799f22 | ||
|
|
011b5bba91 | ||
|
|
3e6a1c6f39 | ||
|
|
76cd106ef7 | ||
|
|
028a8d7e1c | ||
|
|
290ceb048f | ||
|
|
523b6fa7e8 | ||
|
|
3257efb06a | ||
|
|
6083a485de | ||
|
|
baf36d3d67 | ||
|
|
df49e7afcc | ||
|
|
ce373e3e03 | ||
|
|
9b7758da99 | ||
|
|
6ff29db68a | ||
|
|
274c735b4d |
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@@ -32,6 +33,14 @@ export type DiscipleSummary = {
|
|||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A disciple who has a platform account (linked by an admin). */
|
||||||
|
export type DiscipleLink = {
|
||||||
|
discipleId: string;
|
||||||
|
handle: string;
|
||||||
|
name: string;
|
||||||
|
avatarURL: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type LetterCount = { letter: string; count: number };
|
export type LetterCount = { letter: string; count: number };
|
||||||
export type PlaceGroup = { name: string; count: number };
|
export type PlaceGroup = { name: string; count: number };
|
||||||
export type YearGroup = { year: number; count: number };
|
export type YearGroup = { year: number; count: number };
|
||||||
@@ -49,6 +58,9 @@ type Props = {
|
|||||||
years: YearGroup[];
|
years: YearGroup[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
filteredCount: number;
|
filteredCount: number;
|
||||||
|
/** Disciples who have joined the platform — their cards are highlighted and
|
||||||
|
* link through to the profile. Small set (dozens at most). */
|
||||||
|
links: DiscipleLink[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type SortMode = "name-asc" | "name-desc" | "date-asc" | "date-desc" | "place";
|
type SortMode = "name-asc" | "name-desc" | "date-asc" | "date-desc" | "place";
|
||||||
@@ -306,6 +318,97 @@ function Pagination({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Disciple card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One disciple in the grid.
|
||||||
|
*
|
||||||
|
* When `link` is set the disciple has joined the platform, and the card is
|
||||||
|
* highlighted and becomes a link to their profile. The highlight is drawn
|
||||||
|
* entirely from theme tokens (`--primary`, `--ring`) rather than fixed colours,
|
||||||
|
* so it picks up whichever palette the reader has chosen — peacock green under
|
||||||
|
* Tulasī Garden, deep indigo under Yamunā Mist, and so on.
|
||||||
|
*/
|
||||||
|
function DiscipleCard({
|
||||||
|
disciple,
|
||||||
|
link,
|
||||||
|
}: {
|
||||||
|
disciple: DiscipleSummary;
|
||||||
|
link?: DiscipleLink;
|
||||||
|
}) {
|
||||||
|
const locationYear = formatLocationYear(
|
||||||
|
disciple.initiationPlace,
|
||||||
|
disciple.initiationDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const card = (
|
||||||
|
<Card
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-full transition-all",
|
||||||
|
link
|
||||||
|
? // On the platform: tinted surface + primary ring so it reads as
|
||||||
|
// "one of us" at a glance across the grid.
|
||||||
|
"bg-primary/[0.06] ring-2 ring-primary/40 hover:ring-primary/70 hover:shadow-md"
|
||||||
|
: "hover:shadow-md hover:ring-foreground/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="py-3 px-4 space-y-1.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"font-semibold leading-snug truncate",
|
||||||
|
link ? "text-primary" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{disciple.initiatedName}
|
||||||
|
</p>
|
||||||
|
{disciple.suffix && (
|
||||||
|
<Badge
|
||||||
|
variant={link ? "default" : "secondary"}
|
||||||
|
className="shrink-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{disciple.suffix}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{locationYear && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{locationYear}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{link && (
|
||||||
|
<div className="flex items-center gap-1.5 pt-0.5">
|
||||||
|
<UserAvatar
|
||||||
|
src={link.avatarURL}
|
||||||
|
name={link.name}
|
||||||
|
className="size-5"
|
||||||
|
textClassName="text-[9px]"
|
||||||
|
/>
|
||||||
|
<span className="truncate text-xs font-medium text-primary">
|
||||||
|
@{link.handle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!link) return card;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/u/${link.handle}`}
|
||||||
|
aria-label={`${disciple.initiatedName} — view @${link.handle}'s profile`}
|
||||||
|
className="block rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Component
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -318,6 +421,7 @@ export function DisciplesBrowser({
|
|||||||
suffixCounts,
|
suffixCounts,
|
||||||
totalCount,
|
totalCount,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
|
links,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<SortMode>("name-asc");
|
const [sort, setSort] = useState<SortMode>("name-asc");
|
||||||
@@ -334,6 +438,13 @@ export function DisciplesBrowser({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Disciple id → their platform account, for the highlight + profile link.
|
||||||
|
const linkByDiscipleId = useMemo(() => {
|
||||||
|
const map = new Map<string, DiscipleLink>();
|
||||||
|
for (const l of links) map.set(l.discipleId, l);
|
||||||
|
return map;
|
||||||
|
}, [links]);
|
||||||
|
|
||||||
// Build a lookup map for letter counts
|
// Build a lookup map for letter counts
|
||||||
const countByLetter = useMemo(() => {
|
const countByLetter = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
@@ -539,41 +650,13 @@ export function DisciplesBrowser({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
{pageData.map((disciple) => {
|
{pageData.map((disciple) => (
|
||||||
const locationYear = formatLocationYear(
|
<DiscipleCard
|
||||||
disciple.initiationPlace,
|
key={disciple.id}
|
||||||
disciple.initiationDate,
|
disciple={disciple}
|
||||||
);
|
link={linkByDiscipleId.get(disciple.id)}
|
||||||
|
/>
|
||||||
return (
|
))}
|
||||||
<Card
|
|
||||||
key={disciple.id}
|
|
||||||
size="sm"
|
|
||||||
className="transition-all hover:shadow-md hover:ring-foreground/20"
|
|
||||||
>
|
|
||||||
<CardContent className="py-3 px-4 space-y-1.5">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<p className="font-semibold text-foreground leading-snug truncate">
|
|
||||||
{disciple.initiatedName}
|
|
||||||
</p>
|
|
||||||
{disciple.suffix && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="shrink-0 text-[10px]"
|
|
||||||
>
|
|
||||||
{disciple.suffix}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{locationYear && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{locationYear}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getDiscipleYears,
|
getDiscipleYears,
|
||||||
getDiscipleSuffixCounts,
|
getDiscipleSuffixCounts,
|
||||||
getFilteredDiscipleCount,
|
getFilteredDiscipleCount,
|
||||||
|
getDiscipleLinks,
|
||||||
} from "@/lib/disciples";
|
} from "@/lib/disciples";
|
||||||
import type { SuffixFilter } from "@/lib/disciples";
|
import type { SuffixFilter } from "@/lib/disciples";
|
||||||
import { DisciplesBrowser } from "./disciples-browser";
|
import { DisciplesBrowser } from "./disciples-browser";
|
||||||
@@ -62,6 +63,7 @@ export default async function DisciplesPage({
|
|||||||
places,
|
places,
|
||||||
years,
|
years,
|
||||||
suffixCounts,
|
suffixCounts,
|
||||||
|
links,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getTotalDiscipleCount(),
|
getTotalDiscipleCount(),
|
||||||
getFilteredDisciples(letterFilter, activeSuffix),
|
getFilteredDisciples(letterFilter, activeSuffix),
|
||||||
@@ -70,6 +72,7 @@ export default async function DisciplesPage({
|
|||||||
getDisciplePlaces(),
|
getDisciplePlaces(),
|
||||||
getDiscipleYears(),
|
getDiscipleYears(),
|
||||||
getDiscipleSuffixCounts(),
|
getDiscipleSuffixCounts(),
|
||||||
|
getDiscipleLinks(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Serialize dates to ISO strings for the client component
|
// Serialize dates to ISO strings for the client component
|
||||||
@@ -105,6 +108,7 @@ export default async function DisciplesPage({
|
|||||||
years={years}
|
years={years}
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
filteredCount={filteredCount}
|
filteredCount={filteredCount}
|
||||||
|
links={links}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/lib/transcriptions";
|
} from "@/lib/transcriptions";
|
||||||
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
|
import { QuizButton } from "@/components/quiz/quiz-button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
@@ -95,6 +96,9 @@ export default async function TranscriptionPage({
|
|||||||
/>
|
/>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
sticky
|
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={{
|
offline={{
|
||||||
type: "transcript",
|
type: "transcript",
|
||||||
title: t.name,
|
title: t.name,
|
||||||
@@ -168,13 +172,15 @@ export default async function TranscriptionPage({
|
|||||||
|
|
||||||
{/* Play Audio button */}
|
{/* Play Audio button */}
|
||||||
{t.audioUrl && (
|
{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
|
<PlayAudioButton
|
||||||
audioUrl={t.audioUrl}
|
audioUrl={t.audioUrl}
|
||||||
title={t.name}
|
title={t.name}
|
||||||
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||||
sourceUrl={`/transcriptions/${t.slug}`}
|
sourceUrl={`/transcriptions/${t.slug}`}
|
||||||
type={t.type}
|
type={t.type}
|
||||||
|
fallbackYear={t.year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import {
|
|||||||
type AudioTrack,
|
type AudioTrack,
|
||||||
AudioWaveAnimation,
|
AudioWaveAnimation,
|
||||||
} from "@/components/audio-player";
|
} from "@/components/audio-player";
|
||||||
|
import {
|
||||||
|
rememberedQueue,
|
||||||
|
useTranscriptQueue,
|
||||||
|
yearQueue,
|
||||||
|
} from "@/components/audio-player/transcript-queue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
|
|
||||||
@@ -17,6 +22,11 @@ type PlayAudioButtonProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Compact icon-only variant for list items */
|
/** Compact icon-only variant for list items */
|
||||||
variant?: "default" | "compact";
|
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({
|
export function PlayAudioButton({
|
||||||
@@ -27,8 +37,18 @@ export function PlayAudioButton({
|
|||||||
type,
|
type,
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
fallbackYear,
|
||||||
}: PlayAudioButtonProps) {
|
}: 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 isThisTrack = currentTrack?.url === audioUrl;
|
||||||
const isThisPlaying = isThisTrack && isPlaying;
|
const isThisPlaying = isThisTrack && isPlaying;
|
||||||
@@ -44,7 +64,25 @@ export function PlayAudioButton({
|
|||||||
sourceUrl,
|
sourceUrl,
|
||||||
type: type ?? "lecture",
|
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);
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
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 { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
import { parseChapter, parseVerse } from "@/lib/book-reference";
|
import { parseChapter, parseVerse } from "@/lib/book-reference";
|
||||||
import {
|
import {
|
||||||
@@ -417,6 +425,13 @@ export function TranscriptionBookBrowser({
|
|||||||
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
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 activeIdx = flat.findIndex((f) => f.chapter.key === activeKey);
|
||||||
const active = activeIdx >= 0 ? flat[activeIdx] : null;
|
const active = activeIdx >= 0 ? flat[activeIdx] : null;
|
||||||
const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
|
const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
|
||||||
@@ -463,42 +478,44 @@ export function TranscriptionBookBrowser({
|
|||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||||
{/* Top navigation */}
|
<TranscriptQueue items={queueItems}>
|
||||||
<ChapterNav
|
{/* Top navigation */}
|
||||||
prev={prev}
|
<ChapterNav
|
||||||
next={next}
|
prev={prev}
|
||||||
sections={sections}
|
next={next}
|
||||||
activeKey={activeKey}
|
sections={sections}
|
||||||
onSelect={handleSelect}
|
activeKey={activeKey}
|
||||||
drawerTitle={title}
|
onSelect={handleSelect}
|
||||||
/>
|
drawerTitle={title}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Content — every lecture of the active chapter (all years/places) */}
|
{/* Content — every lecture of the active chapter (all years/places) */}
|
||||||
{!active || active.chapter.transcriptions.length === 0 ? (
|
{!active || active.chapter.transcriptions.length === 0 ? (
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
<p className="text-lg font-medium">
|
<p className="text-lg font-medium">
|
||||||
No transcriptions for this chapter
|
No transcriptions for this chapter
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
{active.chapter.transcriptions.map((t) => (
|
{active.chapter.transcriptions.map((t) => (
|
||||||
<LectureRow key={t.slug} t={t} />
|
<LectureRow key={t.slug} t={t} />
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom navigation */}
|
{/* Bottom navigation */}
|
||||||
<ChapterNav
|
<ChapterNav
|
||||||
prev={prev}
|
prev={prev}
|
||||||
next={next}
|
next={next}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
drawerTitle={title}
|
drawerTitle={title}
|
||||||
/>
|
/>
|
||||||
|
</TranscriptQueue>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageHeader } from "@/components/layout/page-header";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Calendar, Clock } from "lucide-react";
|
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";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
@@ -55,58 +56,61 @@ export default async function TranscriptionsByPlacePage({
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
<Card className="overflow-hidden">
|
{/* Next / Previous in the player follow this list. */}
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<TranscriptQueue items={transcriptions}>
|
||||||
{transcriptions.map((t) => (
|
<Card className="overflow-hidden">
|
||||||
<Link
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
key={t.slug}
|
{transcriptions.map((t) => (
|
||||||
href={`/transcriptions/${t.slug}`}
|
<Link
|
||||||
className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
|
key={t.slug}
|
||||||
>
|
href={`/transcriptions/${t.slug}`}
|
||||||
<div className="min-w-0 flex-1">
|
className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
>
|
||||||
<p className="font-medium text-foreground text-sm">
|
<div className="min-w-0 flex-1">
|
||||||
{t.name}
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
</p>
|
<p className="font-medium text-foreground text-sm">
|
||||||
<Badge
|
{t.name}
|
||||||
variant={t.type === "lecture" ? "default" : "secondary"}
|
</p>
|
||||||
className="text-[10px] px-1.5 py-0"
|
<Badge
|
||||||
>
|
variant={t.type === "lecture" ? "default" : "secondary"}
|
||||||
{t.type === "lecture" ? "Lecture" : "Conversation"}
|
className="text-[10px] px-1.5 py-0"
|
||||||
</Badge>
|
>
|
||||||
|
{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>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
|
{t.audioUrl && (
|
||||||
{t.date && (
|
<PlayAudioButton
|
||||||
<span className="flex items-center gap-1">
|
audioUrl={t.audioUrl}
|
||||||
<Calendar className="size-3" />
|
title={t.name}
|
||||||
{t.date}
|
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||||
</span>
|
sourceUrl={`/transcriptions/${t.slug}`}
|
||||||
)}
|
type={t.type}
|
||||||
{t.reference && (
|
variant="compact"
|
||||||
<span className="text-primary/70">{t.reference}</span>
|
/>
|
||||||
)}
|
)}
|
||||||
{t.durationMinutes && t.durationMinutes > 0 && (
|
</Link>
|
||||||
<span className="flex items-center gap-1">
|
))}
|
||||||
<Clock className="size-3" />
|
</CardContent>
|
||||||
{t.durationMinutes} min
|
</Card>
|
||||||
</span>
|
</TranscriptQueue>
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
MapPin,
|
MapPin,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { TranscriptQueue } from "@/components/audio-player/transcript-queue";
|
||||||
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -481,6 +482,16 @@ export function TranscriptionTypeBrowser({
|
|||||||
[activeGroup],
|
[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 activeYearLabel = activeGroup?.label ?? "Unknown";
|
||||||
const drawerTitle = typeLabel;
|
const drawerTitle = typeLabel;
|
||||||
|
|
||||||
@@ -520,46 +531,48 @@ export function TranscriptionTypeBrowser({
|
|||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||||
{/* Top navigation */}
|
<TranscriptQueue items={queueItems}>
|
||||||
<YearNav
|
{/* Top navigation */}
|
||||||
prev={prev}
|
<YearNav
|
||||||
next={next}
|
prev={prev}
|
||||||
groups={yearGroups}
|
next={next}
|
||||||
activeYear={activeYear}
|
groups={yearGroups}
|
||||||
onSelect={handleYearSelect}
|
activeYear={activeYear}
|
||||||
drawerTitle={drawerTitle}
|
onSelect={handleYearSelect}
|
||||||
/>
|
drawerTitle={drawerTitle}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Content — transcriptions grouped by month */}
|
{/* Content — transcriptions grouped by month */}
|
||||||
{monthGroups.length === 0 ? (
|
{monthGroups.length === 0 ? (
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
<p className="text-lg font-medium">
|
<p className="text-lg font-medium">
|
||||||
No transcriptions for this year
|
No transcriptions for this year
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
{monthGroups.map((mg, i) => (
|
{monthGroups.map((mg, i) => (
|
||||||
<MonthSection
|
<MonthSection
|
||||||
key={mg.month}
|
key={mg.month}
|
||||||
group={mg}
|
group={mg}
|
||||||
defaultOpen={i === 0 || monthGroups.length <= 3}
|
defaultOpen={i === 0 || monthGroups.length <= 3}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom navigation */}
|
{/* Bottom navigation */}
|
||||||
<YearNav
|
<YearNav
|
||||||
prev={prev}
|
prev={prev}
|
||||||
next={next}
|
next={next}
|
||||||
groups={yearGroups}
|
groups={yearGroups}
|
||||||
activeYear={activeYear}
|
activeYear={activeYear}
|
||||||
onSelect={handleYearSelect}
|
onSelect={handleYearSelect}
|
||||||
drawerTitle={drawerTitle}
|
drawerTitle={drawerTitle}
|
||||||
/>
|
/>
|
||||||
|
</TranscriptQueue>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react";
|
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";
|
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||||
|
|
||||||
type TranscriptionItem = {
|
type TranscriptionItem = {
|
||||||
@@ -169,6 +170,12 @@ export function TranscriptionYearBrowser({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [transcriptions]);
|
}, [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(
|
const lectureCount = transcriptions.filter(
|
||||||
(t) => t.type === "lecture",
|
(t) => t.type === "lecture",
|
||||||
).length;
|
).length;
|
||||||
@@ -202,19 +209,21 @@ export function TranscriptionYearBrowser({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden">
|
<TranscriptQueue items={queueItems}>
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
<Card className="overflow-hidden">
|
||||||
{monthGroups.map((mg, i) => (
|
<CardContent className="p-0 divide-y divide-border">
|
||||||
<MonthSection
|
{monthGroups.map((mg, i) => (
|
||||||
key={mg.month}
|
<MonthSection
|
||||||
month={mg.month}
|
key={mg.month}
|
||||||
label={mg.label}
|
month={mg.month}
|
||||||
items={mg.items}
|
label={mg.label}
|
||||||
defaultOpen={i === 0 || monthGroups.length <= 3}
|
items={mg.items}
|
||||||
/>
|
defaultOpen={i === 0 || monthGroups.length <= 3}
|
||||||
))}
|
/>
|
||||||
</CardContent>
|
))}
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TranscriptQueue>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -9,23 +9,31 @@
|
|||||||
* Authed visitors don't see the contact-email field — the BFF reads
|
* Authed visitors don't see the contact-email field — the BFF reads
|
||||||
* their account email from the session cookie. Anonymous visitors get
|
* their account email from the session cookie. Anonymous visitors get
|
||||||
* the field and must fill it in so we have a reply path.
|
* 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 Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowLeft, MessageSquare, Send } from "lucide-react";
|
import { ArrowLeft, MessageSquare, Send } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
|
||||||
type Category = "bug" | "feature" | "general" | "other";
|
// 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 CATEGORIES: { value: Category; label: string; description: string }[] = [
|
const FALLBACK_CATEGORIES: FeedbackCategory[] = [
|
||||||
{ value: "general", label: "General feedback", description: "Thoughts, impressions, suggestions — anything else." },
|
{ key: "general", label: "General feedback", description: "Thoughts, impressions, suggestions — anything else." },
|
||||||
{ value: "bug", label: "Bug report", description: "Something is broken or behaving unexpectedly." },
|
{ key: "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." },
|
{ key: "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." },
|
{ key: "other", label: "Other", description: "Doesn't fit the above? Use this." },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function FeedbackPage() {
|
export default function FeedbackPage() {
|
||||||
@@ -33,7 +41,28 @@ export default function FeedbackPage() {
|
|||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const authed = !loading && !!user;
|
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 [message, setMessage] = useState("");
|
||||||
const [contactEmail, setContactEmail] = useState("");
|
const [contactEmail, setContactEmail] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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 (
|
return (
|
||||||
<main className="flex-1 max-w-xl w-full mx-auto px-4 sm:px-6 py-10 sm:py-14">
|
<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
|
<select
|
||||||
id="category"
|
id="category"
|
||||||
value={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"
|
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}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{CATEGORIES.map((c) => (
|
{categories.map((c) => (
|
||||||
<option key={c.value} value={c.value}>{c.label}</option>
|
<option key={c.key} value={c.key}>{c.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SiteHeader } from "@/components/layout/site-header";
|
import { SiteHeader } from "@/components/layout/site-header";
|
||||||
import { SiteSidebar } from "@/components/layout/site-sidebar";
|
import { SiteSidebar } from "@/components/layout/site-sidebar";
|
||||||
import { SiteBottomNav } from "@/components/layout/site-bottom-nav";
|
import { SiteBottomNav } from "@/components/layout/site-bottom-nav";
|
||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
import { SiteFooter } from "@/components/layout/site-footer";
|
import { SiteFooter } from "@/components/layout/site-footer";
|
||||||
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
|
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
|
||||||
import { PushPrompt } from "@/components/push/push-prompt";
|
import { PushPrompt } from "@/components/push/push-prompt";
|
||||||
@@ -28,11 +29,11 @@ export default function AppLayout({
|
|||||||
<>
|
<>
|
||||||
<VerifyEmailBanner />
|
<VerifyEmailBanner />
|
||||||
<SiteSidebar />
|
<SiteSidebar />
|
||||||
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
|
<AppShell>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
{children}
|
{children}
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</AppShell>
|
||||||
<SiteBottomNav />
|
<SiteBottomNav />
|
||||||
<PushPrompt />
|
<PushPrompt />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
type AppNotification,
|
type AppNotification,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
} from "@/lib/social";
|
} from "@/lib/social";
|
||||||
|
import { RichText } from "@/lib/rich-text";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
@@ -275,10 +276,16 @@ function NotificationRow({
|
|||||||
<UserAvatar src={isAdmin ? null : n.actor_avatar_url} name={display} />
|
<UserAvatar src={isAdmin ? null : n.actor_avatar_url} name={display} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{isAdmin ? (
|
{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>
|
<span className="font-semibold">{display}</span>
|
||||||
{n.body ? <span className="block mt-0.5">{n.body}</span> : null}
|
{n.body ? (
|
||||||
</p>
|
<span className="block mt-0.5">
|
||||||
|
<RichText text={n.body} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-foreground leading-snug">
|
<p className="text-sm text-foreground leading-snug">
|
||||||
<span className="font-semibold">{display}</span> {message}
|
<span className="font-semibold">{display}</span> {message}
|
||||||
@@ -350,6 +357,8 @@ function messageFor(n: AppNotification): string {
|
|||||||
return "replied to your comment.";
|
return "replied to your comment.";
|
||||||
case "comment_mention":
|
case "comment_mention":
|
||||||
return "mentioned you in a comment.";
|
return "mentioned you in a comment.";
|
||||||
|
case "disciple_joined":
|
||||||
|
return "— a direct disciple of Śrīla Prabhupāda — has joined Prabhupāda Says.";
|
||||||
case "admin_message":
|
case "admin_message":
|
||||||
return n.body ?? "sent you a message.";
|
return n.body ?? "sent you a message.";
|
||||||
default:
|
default:
|
||||||
|
|||||||
+23
-6
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/auth/auth-provider";
|
|||||||
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
|
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
|
||||||
import { VaniOfTheDayCTA } from "@/components/vani/vani-of-the-day-cta";
|
import { VaniOfTheDayCTA } from "@/components/vani/vani-of-the-day-cta";
|
||||||
import { HomeFeed } from "@/components/feed/home-feed";
|
import { HomeFeed } from "@/components/feed/home-feed";
|
||||||
|
import { QuizzesCTA } from "@/components/quiz/quizzes-cta";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* / — the Home (Feed) gate.
|
* / — 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
|
// mobile this max-width is wider than the viewport, so it's full-bleed
|
||||||
// as before.
|
// as before.
|
||||||
<main className="flex-1 max-w-lg w-full mx-auto px-4 sm:px-6 py-6">
|
<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">
|
<div className="mb-6">
|
||||||
<SadhnaStreakCTA />
|
<div className="flex h-24 gap-3">
|
||||||
<VaniOfTheDayCTA />
|
{/* 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>
|
||||||
<HomeFeed />
|
<HomeFeed />
|
||||||
</main>
|
</main>
|
||||||
@@ -88,9 +97,17 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Daily sādhana streak + Vāṇī of the day — entry points for visitors. */}
|
{/* 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">
|
<div className="mt-6 w-full max-w-md">
|
||||||
<SadhnaStreakCTA />
|
<div className="flex h-24 gap-3">
|
||||||
<VaniOfTheDayCTA />
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -152,7 +152,9 @@ export default function FeedbacksPage() {
|
|||||||
function FeedbackCard({ feedback: f }: { feedback: Feedback }) {
|
function FeedbackCard({ feedback: f }: { feedback: Feedback }) {
|
||||||
const status = statusOf(f);
|
const status = statusOf(f);
|
||||||
const meta = STATUS_META[status];
|
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 (
|
return (
|
||||||
<li className="rounded-xl border border-border bg-card p-4 sm:p-5">
|
<li className="rounded-xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function LegalSettingsPage() {
|
|||||||
title="Legal"
|
title="Legal"
|
||||||
>
|
>
|
||||||
<NavList>
|
<NavList>
|
||||||
|
<NavRow href="/about" label="About Us" />
|
||||||
<NavRow href="/terms" label="Terms of Service" />
|
<NavRow href="/terms" label="Terms of Service" />
|
||||||
<NavRow href="/privacy" label="Privacy Policy" />
|
<NavRow href="/privacy" label="Privacy Policy" />
|
||||||
<NavRow href="/data-deletion" label="Steps for data deletion" />
|
<NavRow href="/data-deletion" label="Steps for data deletion" />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
DownloadCloud,
|
DownloadCloud,
|
||||||
LogOut,
|
LogOut,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
|
MessagesSquare,
|
||||||
Scale,
|
Scale,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
UserRound,
|
UserRound,
|
||||||
@@ -49,6 +50,12 @@ export default function SettingsPage() {
|
|||||||
label="Reading preferences"
|
label="Reading preferences"
|
||||||
description="Theme, palette, font, and size — synced across devices."
|
description="Theme, palette, font, and size — synced across devices."
|
||||||
/>
|
/>
|
||||||
|
<NavRow
|
||||||
|
href="/settings/chat"
|
||||||
|
icon={<MessagesSquare className="size-5" />}
|
||||||
|
label="Chat"
|
||||||
|
description="Read receipts, who can message you, and Kautilya training."
|
||||||
|
/>
|
||||||
<NavRow
|
<NavRow
|
||||||
href="/settings/notifications"
|
href="/settings/notifications"
|
||||||
icon={<Bell className="size-5" />}
|
icon={<Bell className="size-5" />}
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export default function PublicProfileLayout(props: {
|
|||||||
interactiveCounts={false}
|
interactiveCounts={false}
|
||||||
joinedAt={data.joined_at}
|
joinedAt={data.joined_at}
|
||||||
country={data.country}
|
country={data.country}
|
||||||
|
isDirectDisciple={!!data.is_direct_disciple}
|
||||||
|
bio={data.bio}
|
||||||
/>
|
/>
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -174,6 +176,8 @@ export default function PublicProfileLayout(props: {
|
|||||||
enableActions
|
enableActions
|
||||||
joinedAt={data.joined_at}
|
joinedAt={data.joined_at}
|
||||||
country={data.country}
|
country={data.country}
|
||||||
|
isDirectDisciple={!!data.is_direct_disciple}
|
||||||
|
bio={data.bio}
|
||||||
/>
|
/>
|
||||||
<ProfileTabs tabs={tabs} />
|
<ProfileTabs tabs={tabs} />
|
||||||
<PublicProfileContext.Provider value={data}>
|
<PublicProfileContext.Provider value={data}>
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
|
|||||||
name={user.name}
|
name={user.name}
|
||||||
handle={user.handle}
|
handle={user.handle}
|
||||||
joinedAt={user.created_at}
|
joinedAt={user.created_at}
|
||||||
|
isDirectDisciple={!!user.is_direct_disciple}
|
||||||
|
bio={user.bio}
|
||||||
editHref="/settings/profile"
|
editHref="/settings/profile"
|
||||||
postCount={counts?.posts}
|
postCount={counts?.posts}
|
||||||
followerCount={counts?.followers}
|
followerCount={counts?.followers}
|
||||||
|
|||||||
+15
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
|
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
|
||||||
import { ogDefault } from "@/lib/og";
|
import { ogDefault } from "@/lib/og";
|
||||||
@@ -8,6 +8,7 @@ import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
|
|||||||
import { AuthProvider } from "@/components/auth/auth-provider";
|
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
import { AuthGateProvider } from "@/components/auth/auth-gate";
|
import { AuthGateProvider } from "@/components/auth/auth-gate";
|
||||||
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
|
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
|
||||||
|
import { BroadcastModal } from "@/components/notifications/broadcast-modal";
|
||||||
import { SharePostProvider } from "@/components/social/share-post-provider";
|
import { SharePostProvider } from "@/components/social/share-post-provider";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
|
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
|
||||||
@@ -43,6 +44,16 @@ const bricolage = Bricolage_Grotesque({
|
|||||||
display: "swap",
|
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 = {
|
export const metadata: Metadata = {
|
||||||
// Resolves all relative OG/Twitter image URLs and canonical links to
|
// Resolves all relative OG/Twitter image URLs and canonical links to
|
||||||
// absolute URLs (required for correct social previews + SEO; Next warns
|
// 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
|
{/* Toaster and FloatingPlayer are global — toasts and
|
||||||
currently-playing audio survive navigation between
|
currently-playing audio survive navigation between
|
||||||
route groups. */}
|
route groups. */}
|
||||||
|
{/* Announcements: at most one modal, only when signed
|
||||||
|
in, dismissed server-side so it never repeats. */}
|
||||||
|
<BroadcastModal />
|
||||||
<FloatingPlayer />
|
<FloatingPlayer />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ServiceWorkerRegistrar />
|
<ServiceWorkerRegistrar />
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ type AudioPlayerState = {
|
|||||||
type AudioPlayerActions = {
|
type AudioPlayerActions = {
|
||||||
play: (track: AudioTrack) => void;
|
play: (track: AudioTrack) => void;
|
||||||
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => 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;
|
pause: () => void;
|
||||||
resume: () => void;
|
resume: () => void;
|
||||||
togglePlayPause: () => void;
|
togglePlayPause: () => void;
|
||||||
@@ -469,6 +475,14 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
[saveCurrent],
|
[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(() => {
|
const pause = useCallback(() => {
|
||||||
audioRef.current?.pause();
|
audioRef.current?.pause();
|
||||||
saveCurrent();
|
saveCurrent();
|
||||||
@@ -630,6 +644,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
currentIndex,
|
currentIndex,
|
||||||
play,
|
play,
|
||||||
playPlaylist,
|
playPlaylist,
|
||||||
|
attachQueue,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
togglePlayPause,
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,8 +89,13 @@ export function NewUsersStrip({ users }: { users: UserSummary[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bleed to the section edges (-mx-5) but keep a px-5 gutter so the
|
{/* Bleed to the page edges, with a matching inner gutter so the first and
|
||||||
first/last tiles have breathing room as they scroll past. */}
|
last tiles still have breathing room as they scroll past.
|
||||||
|
The negative margin MUST match the feed container's horizontal padding
|
||||||
|
(`main` is `px-4 sm:px-6`). It was `-mx-5` (20px) against a 16px
|
||||||
|
mobile padding, so the row hung 4px past each edge and gave the whole
|
||||||
|
document a horizontal scrollbar. Keep these three in lockstep with the
|
||||||
|
page padding if it ever changes. */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
@@ -98,7 +103,7 @@ export function NewUsersStrip({ users }: { users: UserSummary[] }) {
|
|||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
onClickCapture={onClickCapture}
|
onClickCapture={onClickCapture}
|
||||||
className="-mx-5 flex gap-3 overflow-x-auto px-5 py-1 snap-x scroll-px-5 cursor-grab select-none active:cursor-grabbing [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
className="-mx-4 sm:-mx-6 flex gap-3 overflow-x-auto px-4 sm:px-6 py-1 snap-x scroll-px-4 sm:scroll-px-6 cursor-grab select-none active:cursor-grabbing [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||||
>
|
>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* library.
|
* library.
|
||||||
*
|
*
|
||||||
* Two sections, top to bottom:
|
* 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.
|
* rendered as full-width feature tiles with longer copy.
|
||||||
* 2. Explore — a six-card asymmetric grid for the remaining sections.
|
* 2. Explore — a six-card asymmetric grid for the remaining sections.
|
||||||
*
|
*
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
PenLine,
|
PenLine,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { QuizzesCTA } from "@/components/quiz/quizzes-cta";
|
||||||
|
|
||||||
type Feature = {
|
type Feature = {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -112,7 +114,11 @@ export function KnowledgebaseIndex() {
|
|||||||
<div>
|
<div>
|
||||||
<section className="py-4">
|
<section className="py-4">
|
||||||
<SectionHeading eyebrow="Start here" title="Three doors in" />
|
<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) => (
|
{FEATURED.map((f) => (
|
||||||
<FeaturedTile key={f.href} feature={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" />
|
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
||||||
</div>
|
</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}
|
{feature.label}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,9 +25,10 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { Check, Link2 } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CopyPageLinkButton } from "@/components/layout/copy-page-link-button";
|
||||||
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
|
||||||
|
import { QuizButton } from "@/components/quiz/quiz-button";
|
||||||
import type { OfflineDescriptor } from "@/lib/offline";
|
import type { OfflineDescriptor } from "@/lib/offline";
|
||||||
|
|
||||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||||
@@ -71,6 +72,7 @@ export function PageHeader({
|
|||||||
bare = false,
|
bare = false,
|
||||||
sticky = false,
|
sticky = false,
|
||||||
offline,
|
offline,
|
||||||
|
quiz = true,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Drop the bordered chrome — useful for pages that want flush title text. */
|
/** Drop the bordered chrome — useful for pages that want flush title text. */
|
||||||
@@ -89,6 +91,13 @@ export function PageHeader({
|
|||||||
* visible gap.
|
* visible gap.
|
||||||
*/
|
*/
|
||||||
sticky?: boolean;
|
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;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -144,43 +153,6 @@ export function PageHeader({
|
|||||||
return () => window.removeEventListener("scroll", onScroll);
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
}, [sticky]);
|
}, [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 (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -216,25 +188,10 @@ export function PageHeader({
|
|||||||
|
|
||||||
{/* Page-level controls, anchored at the header's bottom. */}
|
{/* Page-level controls, anchored at the header's bottom. */}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<button
|
{/* First, so it reads as belonging to the title above it. Renders
|
||||||
type="button"
|
nothing on pages without a published quiz. */}
|
||||||
onClick={copyPageLink}
|
{quiz && isContentPath(pathname) ? <QuizButton path={pathname} /> : null}
|
||||||
aria-label="Copy link to this page"
|
<CopyPageLinkButton />
|
||||||
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>
|
|
||||||
{resolvedOffline ? (
|
{resolvedOffline ? (
|
||||||
<SaveOfflineButton descriptor={resolvedOffline} />
|
<SaveOfflineButton descriptor={resolvedOffline} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -13,9 +13,13 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Home, Search, User as UserIcon } from "lucide-react";
|
import { Home, MessageCircle, Search, User as UserIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
NavUnreadBadge,
|
||||||
|
useChatUnread,
|
||||||
|
} from "@/components/layout/nav-unread-badge";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
@@ -28,7 +32,11 @@ type Item = {
|
|||||||
|
|
||||||
export function SiteBottomNav() {
|
export function SiteBottomNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const hidden = useHidesAppChrome();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
// Read here rather than inside the badge so the count can also reach the
|
||||||
|
// link's accessible name — the badge itself is decorative.
|
||||||
|
const chatUnread = useChatUnread();
|
||||||
|
|
||||||
const items: Item[] = [
|
const items: Item[] = [
|
||||||
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
||||||
@@ -52,9 +60,9 @@ export function SiteBottomNav() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/ask",
|
href: "/chats",
|
||||||
label: "Kautilya",
|
label: "Chat",
|
||||||
icon: <KautilyaIcon className="size-5" />,
|
icon: <MessageCircle className="size-5" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: user ? "/u/me" : "/login",
|
href: user ? "/u/me" : "/login",
|
||||||
@@ -91,6 +99,9 @@ export function SiteBottomNav() {
|
|||||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A chat thread owns the bottom edge — its composer lives there.
|
||||||
|
if (hidden) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="Primary"
|
aria-label="Primary"
|
||||||
@@ -114,7 +125,11 @@ export function SiteBottomNav() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={
|
||||||
|
item.href === "/chats" && chatUnread > 0
|
||||||
|
? `${item.label} (${chatUnread} unread)`
|
||||||
|
: item.label
|
||||||
|
}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-12 h-12 rounded-md flex flex-col items-center justify-center",
|
"w-12 h-12 rounded-md flex flex-col items-center justify-center",
|
||||||
@@ -123,7 +138,11 @@ export function SiteBottomNav() {
|
|||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.href === "/chats" ? (
|
||||||
|
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
|
||||||
|
) : (
|
||||||
|
item.icon
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import Link from "next/link";
|
|||||||
import { ReadingSettings } from "@/components/reading-settings";
|
import { ReadingSettings } from "@/components/reading-settings";
|
||||||
import { NotificationBell } from "@/components/layout/notification-bell";
|
import { NotificationBell } from "@/components/layout/notification-bell";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -33,6 +34,10 @@ export function SiteHeader() {
|
|||||||
// auth check we treat `loading` as anonymous so the gear doesn't flash
|
// 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.
|
// in and out — once auth resolves, it hides for signed-in users.
|
||||||
const showMobileGear = !loading && !user;
|
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 (
|
return (
|
||||||
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
|
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Menu } from "lucide-react";
|
import { Menu } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
@@ -43,6 +44,10 @@ export function MobileNavToggle({
|
|||||||
items: ReadonlyArray<NavItem>;
|
items: ReadonlyArray<NavItem>;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
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 (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
||||||
|
|||||||
@@ -34,11 +34,16 @@ import {
|
|||||||
DownloadCloud,
|
DownloadCloud,
|
||||||
FolderClosed,
|
FolderClosed,
|
||||||
Home,
|
Home,
|
||||||
|
MessageCircle,
|
||||||
LogIn,
|
LogIn,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
NavUnreadBadge,
|
||||||
|
useChatUnread,
|
||||||
|
} from "@/components/layout/nav-unread-badge";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { ReadingSettings } from "@/components/reading-settings";
|
import { ReadingSettings } from "@/components/reading-settings";
|
||||||
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
||||||
@@ -77,9 +82,9 @@ const TOP_ITEMS: NavItem[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/ask",
|
href: "/chats",
|
||||||
label: "Kautilya",
|
label: "Chats",
|
||||||
icon: <KautilyaIcon className="size-5" />,
|
icon: <MessageCircle className="size-5" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/u/me/collections",
|
href: "/u/me/collections",
|
||||||
@@ -176,10 +181,15 @@ function SidebarLink({
|
|||||||
* settings popover is up) and the mouse isn't actually hovering. */
|
* settings popover is up) and the mouse isn't actually hovering. */
|
||||||
expandedOverride?: boolean;
|
expandedOverride?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const chatUnread = useChatUnread();
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={
|
||||||
|
item.href === "/chats" && chatUnread > 0
|
||||||
|
? `${item.label} (${chatUnread} unread)`
|
||||||
|
: item.label
|
||||||
|
}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||||
@@ -189,7 +199,13 @@ function SidebarLink({
|
|||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{item.icon}</span>
|
<span className="shrink-0">
|
||||||
|
{item.href === "/chats" ? (
|
||||||
|
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
|
||||||
|
) : (
|
||||||
|
item.icon
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-opacity duration-150",
|
"transition-opacity duration-150",
|
||||||
|
|||||||
@@ -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,
|
pushSupported,
|
||||||
} from "@/lib/push";
|
} from "@/lib/push";
|
||||||
|
|
||||||
const EVENTS: { key: PrefEvent; label: string; description: string }[] = [
|
const EVENTS: {
|
||||||
|
key: PrefEvent;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
/** Channels this event can actually be delivered on. Omit for all three.
|
||||||
|
* A cell outside this set renders as "—" rather than a live toggle, so we
|
||||||
|
* never show a switch that silently does nothing. */
|
||||||
|
channels?: PrefChannel[];
|
||||||
|
}[] = [
|
||||||
{
|
{
|
||||||
key: "follows",
|
key: "follows",
|
||||||
label: "Follows",
|
label: "Follows",
|
||||||
@@ -42,6 +50,23 @@ const EVENTS: { key: PrefEvent; label: string; description: string }[] = [
|
|||||||
label: "Sādhana reminders",
|
label: "Sādhana reminders",
|
||||||
description: "Daily nudges to chant your rounds and share a verse.",
|
description: "Daily nudges to chant your rounds and share a verse.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "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 }[] = [
|
const CHANNELS: { key: PrefChannel; label: string }[] = [
|
||||||
@@ -73,7 +98,9 @@ export function NotificationMatrix() {
|
|||||||
|
|
||||||
async function toggle(event: PrefEvent, channel: PrefChannel) {
|
async function toggle(event: PrefEvent, channel: PrefChannel) {
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
const next = !prefs[event][channel];
|
// Default-on when the key is absent: prefs are stored as opt-outs, and a
|
||||||
|
// BFF that predates this category simply omits it.
|
||||||
|
const next = !(prefs[event]?.[channel] ?? true);
|
||||||
setBusyCell(`${event}/${channel}`);
|
setBusyCell(`${event}/${channel}`);
|
||||||
try {
|
try {
|
||||||
// Turning a push cell ON only helps if this device can receive push —
|
// Turning a push cell ON only helps if this device can receive push —
|
||||||
@@ -112,7 +139,11 @@ export function NotificationMatrix() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header row */}
|
||||||
<span aria-hidden />
|
<span aria-hidden />
|
||||||
{CHANNELS.map((c) => (
|
{CHANNELS.map((c) => (
|
||||||
@@ -132,23 +163,34 @@ export function NotificationMatrix() {
|
|||||||
{event.description}
|
{event.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{CHANNELS.map((channel) => (
|
{CHANNELS.map((channel) => {
|
||||||
<div
|
const deliverable =
|
||||||
key={channel.key}
|
!event.channels || event.channels.includes(channel.key);
|
||||||
className="flex justify-center border-t border-border py-3"
|
return (
|
||||||
>
|
<div
|
||||||
{prefs ? (
|
key={channel.key}
|
||||||
<ToggleSwitch
|
className="flex justify-center border-t border-border py-3"
|
||||||
checked={prefs[event.key][channel.key]}
|
>
|
||||||
disabled={busyCell === `${event.key}/${channel.key}`}
|
{!deliverable ? (
|
||||||
onToggle={() => toggle(event.key, channel.key)}
|
<span
|
||||||
label={`${event.label} — ${channel.label}`}
|
className="text-xs text-muted-foreground"
|
||||||
/>
|
title={`${event.label} isn't delivered by ${channel.label.toLowerCase()}`}
|
||||||
) : (
|
>
|
||||||
<span className="text-xs text-muted-foreground">…</span>
|
—
|
||||||
)}
|
</span>
|
||||||
</div>
|
) : prefs ? (
|
||||||
))}
|
<ToggleSwitch
|
||||||
|
checked={prefs[event.key]?.[channel.key] ?? true}
|
||||||
|
disabled={busyCell === `${event.key}/${channel.key}`}
|
||||||
|
onToggle={() => toggle(event.key, channel.key)}
|
||||||
|
label={`${event.label} — ${channel.label}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,13 +5,18 @@
|
|||||||
* WebSocket connection to our Soketi (Pusher-compatible) server so the
|
* WebSocket connection to our Soketi (Pusher-compatible) server so the
|
||||||
* bell badge + a toast update in realtime.
|
* bell badge + a toast update in realtime.
|
||||||
*
|
*
|
||||||
* Transport: subscribes to the PUBLIC channel `notifications.{userId}` and
|
* Transport: subscribes to the PRIVATE channel `private-notifications.{userId}`
|
||||||
* binds the `notification` event the BFF triggers on each follow-request /
|
* and binds the `notification` event the BFF triggers on each follow-request /
|
||||||
* follow-accept. The event is a lightweight nudge — we bump the unread
|
* follow-accept. The event is a lightweight nudge — we bump the unread
|
||||||
* count locally and toast — while the authoritative list/count still come
|
* count locally and toast — while the authoritative list/count still come
|
||||||
* from the REST endpoints. (Public channel for now; once Soketi App auth
|
* from the REST endpoints.
|
||||||
* is configured, switch to a `private-` channel + a /broadcasting/auth
|
*
|
||||||
* endpoint — only the channel name and a Pusher `authEndpoint` change.)
|
* The channel is private so a subscription requires a signature the BFF only
|
||||||
|
* issues to that user; on the old public channel anyone who knew a user's id
|
||||||
|
* could listen in. Signing goes through `channelAuthorizer` (see
|
||||||
|
* lib/realtime.ts). The BFF still publishes to the old public channel too, so
|
||||||
|
* a service-worker-cached bundle running this file's previous version keeps
|
||||||
|
* working until it updates.
|
||||||
*
|
*
|
||||||
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
|
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
|
||||||
* realtime silently disables and the bell falls back to its fetched count.
|
* realtime silently disables and the bell falls back to its fetched count.
|
||||||
@@ -26,8 +31,10 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { channelAuthorizer } from "@/lib/realtime";
|
||||||
import { feed, notifications } from "@/lib/social";
|
import { feed, notifications } from "@/lib/social";
|
||||||
import { sadhna } from "@/lib/sadhna";
|
import { sadhna } from "@/lib/sadhna";
|
||||||
|
import { chat } from "@/lib/chat";
|
||||||
|
|
||||||
type NotificationsContextValue = {
|
type NotificationsContextValue = {
|
||||||
/** Live unread count for the header bell. */
|
/** Live unread count for the header bell. */
|
||||||
@@ -38,6 +45,30 @@ type NotificationsContextValue = {
|
|||||||
refreshUnread: () => void;
|
refreshUnread: () => void;
|
||||||
/** Zero the badge locally (after the page marks everything read). */
|
/** Zero the badge locally (after the page marks everything read). */
|
||||||
clearUnread: () => void;
|
clearUnread: () => void;
|
||||||
|
/**
|
||||||
|
* Last chat message seen over realtime, or null. Chat deliberately does NOT
|
||||||
|
* flow through the notification tray — a new message highlights its row in
|
||||||
|
* the inbox instead — so it rides the same private channel under its own
|
||||||
|
* event name and is surfaced here rather than bumping `unread`.
|
||||||
|
*/
|
||||||
|
lastChatActivity: ChatActivity | null;
|
||||||
|
/** 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>(
|
const NotificationsContext = createContext<NotificationsContextValue | null>(
|
||||||
@@ -70,6 +101,8 @@ function messageFor(e: IncomingEvent): string {
|
|||||||
return `${who} replied to your comment`;
|
return `${who} replied to your comment`;
|
||||||
case "comment_mention":
|
case "comment_mention":
|
||||||
return `${who} mentioned you in a comment`;
|
return `${who} mentioned you in a comment`;
|
||||||
|
case "disciple_joined":
|
||||||
|
return `${who}, a direct disciple of Śrīla Prabhupāda, has joined`;
|
||||||
case "admin_message":
|
case "admin_message":
|
||||||
return e.body || "A message from Prabhupāda Says";
|
return e.body || "A message from Prabhupāda Says";
|
||||||
default:
|
default:
|
||||||
@@ -84,7 +117,12 @@ export function NotificationsProvider({
|
|||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [unread, setUnread] = useState(0);
|
const [unread, setUnread] = useState(0);
|
||||||
|
const [chatUnread, setChatUnread] = useState(0);
|
||||||
|
const [lastBroadcastAt, setLastBroadcastAt] = useState(0);
|
||||||
const [lastEventAt, setLastEventAt] = useState(0);
|
const [lastEventAt, setLastEventAt] = useState(0);
|
||||||
|
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const refreshUnread = useCallback(() => {
|
const refreshUnread = useCallback(() => {
|
||||||
notifications
|
notifications
|
||||||
@@ -97,12 +135,22 @@ export function NotificationsProvider({
|
|||||||
|
|
||||||
const clearUnread = useCallback(() => setUnread(0), []);
|
const clearUnread = useCallback(() => setUnread(0), []);
|
||||||
|
|
||||||
|
const refreshChatUnread = useCallback(() => {
|
||||||
|
chat
|
||||||
|
.unreadCount()
|
||||||
|
.then(setChatUnread)
|
||||||
|
.catch(() => {
|
||||||
|
/* badge is non-critical */
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Seed the count whenever the signed-in user changes (async — no
|
// Seed the count whenever the signed-in user changes (async — no
|
||||||
// synchronous setState in the effect body).
|
// synchronous setState in the effect body).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
refreshUnread();
|
refreshUnread();
|
||||||
}, [user?.id, refreshUnread]);
|
refreshChatUnread();
|
||||||
|
}, [user?.id, refreshUnread, refreshChatUnread]);
|
||||||
|
|
||||||
// Report the browser's timezone (once per signed-in session) so sādhana
|
// Report the browser's timezone (once per signed-in session) so sādhana
|
||||||
// reminders fire at the user's local clock time. Best-effort.
|
// reminders fire at the user's local clock time. Best-effort.
|
||||||
@@ -131,7 +179,7 @@ export function NotificationsProvider({
|
|||||||
};
|
};
|
||||||
window.addEventListener("pagehide", onLeave);
|
window.addEventListener("pagehide", onLeave);
|
||||||
return () => window.removeEventListener("pagehide", onLeave);
|
return () => window.removeEventListener("pagehide", onLeave);
|
||||||
}, [userId]);
|
}, [userId, refreshChatUnread]);
|
||||||
|
|
||||||
// Open the realtime connection while signed in.
|
// Open the realtime connection while signed in.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,7 +191,7 @@ export function NotificationsProvider({
|
|||||||
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
|
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
|
||||||
const forceTLS =
|
const forceTLS =
|
||||||
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
|
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
|
||||||
const channelName = `notifications.${userId}`;
|
const channelName = `private-notifications.${userId}`;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// Dynamic import keeps pusher-js out of SSR (it touches window) and the
|
// Dynamic import keeps pusher-js out of SSR (it touches window) and the
|
||||||
@@ -160,8 +208,36 @@ export function NotificationsProvider({
|
|||||||
disableStats: true,
|
disableStats: true,
|
||||||
// Ignored when wsHost is set, but required by the type.
|
// Ignored when wsHost is set, but required by the type.
|
||||||
cluster: "",
|
cluster: "",
|
||||||
|
// Private channels are refused without a signature from the BFF.
|
||||||
|
channelAuthorization: { customHandler: channelAuthorizer },
|
||||||
});
|
});
|
||||||
const channel = pusher.subscribe(channelName);
|
const channel = pusher.subscribe(channelName);
|
||||||
|
// Authorization can legitimately fail (expired session, realtime not
|
||||||
|
// configured). Realtime is a nudge, not the source of truth, so degrade
|
||||||
|
// quietly — the bell keeps working off its fetched count.
|
||||||
|
channel.bind("pusher:subscription_error", (status: unknown) => {
|
||||||
|
console.warn("notifications: realtime subscription failed", status);
|
||||||
|
});
|
||||||
|
// Chat activity: no toast, 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) => {
|
channel.bind("notification", (data: IncomingEvent) => {
|
||||||
setUnread((u) => u + 1);
|
setUnread((u) => u + 1);
|
||||||
setLastEventAt(Date.now());
|
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 = () => {
|
cleanup = () => {
|
||||||
|
announcements.unbind_all();
|
||||||
|
pusher.unsubscribe("broadcasts");
|
||||||
channel.unbind_all();
|
channel.unbind_all();
|
||||||
pusher.unsubscribe(channelName);
|
pusher.unsubscribe(channelName);
|
||||||
pusher.disconnect();
|
pusher.disconnect();
|
||||||
@@ -188,11 +275,20 @@ export function NotificationsProvider({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
};
|
};
|
||||||
}, [userId]);
|
}, [userId, refreshChatUnread]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationsContext.Provider
|
<NotificationsContext.Provider
|
||||||
value={{ unread, lastEventAt, refreshUnread, clearUnread }}
|
value={{
|
||||||
|
unread,
|
||||||
|
lastEventAt,
|
||||||
|
refreshUnread,
|
||||||
|
clearUnread,
|
||||||
|
lastChatActivity,
|
||||||
|
chatUnread,
|
||||||
|
refreshChatUnread,
|
||||||
|
lastBroadcastAt,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</NotificationsContext.Provider>
|
</NotificationsContext.Provider>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ import { countryName } from "@/lib/countries";
|
|||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
|
import { MessageButton } from "@/components/profile/message-button";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
@@ -206,8 +207,11 @@ export function ProfileActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{primary}
|
{primary}
|
||||||
|
{/* Offered only when the target's settings allow it — see
|
||||||
|
MessageButton. */}
|
||||||
|
<MessageButton handle={handle} name={name} relationship={rel} />
|
||||||
{/* Block / Report / Remove are signed-in-only (their BFF routes
|
{/* Block / Report / Remove are signed-in-only (their BFF routes
|
||||||
require auth) and only appear when `authed`. "More info" is
|
require auth) and only appear when `authed`. "More info" is
|
||||||
public, so the menu always renders with at least that item. */}
|
public, so the menu always renders with at least that item. */}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import { compressImage } from "@/lib/compress-image";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { MentionTextarea } from "@/components/social/mention-textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Profile bio cap. Mirrors the BFF's service.MaxBioRunes. */
|
||||||
|
const BIO_MAX = 200;
|
||||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
|
|
||||||
@@ -70,6 +75,7 @@ export function ProfileEditForm({
|
|||||||
const [name, setName] = useState(user.name);
|
const [name, setName] = useState(user.name);
|
||||||
const [handle, setHandle] = useState(user.handle);
|
const [handle, setHandle] = useState(user.handle);
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
|
||||||
|
const [bio, setBio] = useState<string>(user.bio ?? "");
|
||||||
const [country, setCountry] = useState<string>(user.country ?? "");
|
const [country, setCountry] = useState<string>(user.country ?? "");
|
||||||
// Devotional details. Each "" means "not specified".
|
// Devotional details. Each "" means "not specified".
|
||||||
const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? "");
|
const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? "");
|
||||||
@@ -92,11 +98,15 @@ export function ProfileEditForm({
|
|||||||
const origRounds =
|
const origRounds =
|
||||||
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "";
|
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "";
|
||||||
const effInitiatedAt = initiated ? initiatedAt : "";
|
const effInitiatedAt = initiated ? initiatedAt : "";
|
||||||
|
// Spread, not .length — counts code points so emoji and Devanāgarī match
|
||||||
|
// the server's rune-based cap instead of over-counting UTF-16 units.
|
||||||
|
const bioCount = [...bio.trim()].length;
|
||||||
|
|
||||||
const dirty =
|
const dirty =
|
||||||
name.trim() !== user.name ||
|
name.trim() !== user.name ||
|
||||||
handle.trim() !== user.handle ||
|
handle.trim() !== user.handle ||
|
||||||
avatarUrl !== user.avatar_url ||
|
avatarUrl !== user.avatar_url ||
|
||||||
|
bio.trim() !== (user.bio ?? "") ||
|
||||||
country !== (user.country ?? "") ||
|
country !== (user.country ?? "") ||
|
||||||
sampradaya !== (user.sampradaya ?? "") ||
|
sampradaya !== (user.sampradaya ?? "") ||
|
||||||
gender !== (user.gender ?? "") ||
|
gender !== (user.gender ?? "") ||
|
||||||
@@ -108,6 +118,13 @@ export function ProfileEditForm({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Counted in code points to match the BFF's rune-based cap, so a bio in
|
||||||
|
// Devanāgarī is measured the same way as one in Latin script.
|
||||||
|
if ([...bio.trim()].length > BIO_MAX) {
|
||||||
|
setError(`Bio must be ${BIO_MAX} characters or fewer.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Client-side guard for the round count (the BFF re-checks 1–1008).
|
// Client-side guard for the round count (the BFF re-checks 1–1008).
|
||||||
if (rounds !== "") {
|
if (rounds !== "") {
|
||||||
const n = Number(rounds);
|
const n = Number(rounds);
|
||||||
@@ -126,6 +143,8 @@ export function ProfileEditForm({
|
|||||||
if (name.trim() !== user.name) patch.name = name.trim();
|
if (name.trim() !== user.name) patch.name = name.trim();
|
||||||
if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? "";
|
if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? "";
|
||||||
if (handle.trim() !== user.handle) patch.handle = handle.trim();
|
if (handle.trim() !== user.handle) patch.handle = handle.trim();
|
||||||
|
// "" clears the bio.
|
||||||
|
if (bio.trim() !== (user.bio ?? "")) patch.bio = bio.trim();
|
||||||
if (country !== (user.country ?? "")) patch.country = country;
|
if (country !== (user.country ?? "")) patch.country = country;
|
||||||
// "" clears the lineage; a code sets it.
|
// "" clears the lineage; a code sets it.
|
||||||
if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya;
|
if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya;
|
||||||
@@ -221,6 +240,40 @@ export function ProfileEditForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="profile-bio">Bio</Label>
|
||||||
|
{/* MentionTextarea (shared with the post composer and comment box)
|
||||||
|
gives us the debounced "@" suggestion list for free — typing an
|
||||||
|
@token opens a people picker prioritised following → followers. */}
|
||||||
|
<MentionTextarea
|
||||||
|
id="profile-bio"
|
||||||
|
value={bio}
|
||||||
|
onChange={setBio}
|
||||||
|
rows={3}
|
||||||
|
maxLength={BIO_MAX}
|
||||||
|
placeholder="A little about you… mention devotees with @handle"
|
||||||
|
// Mirrors the <Input> primitive's chrome so the bio sits flush
|
||||||
|
// with the other fields (auto height instead of the fixed h-10).
|
||||||
|
className="w-full min-w-0 resize-y rounded-lg border border-input bg-card px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-card"
|
||||||
|
/>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Type <span className="font-medium text-foreground">@</span> to
|
||||||
|
mention another devotee — mentions become links on your profile.
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-xs tabular-nums",
|
||||||
|
bioCount > BIO_MAX
|
||||||
|
? "font-medium text-destructive"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bioCount}/{BIO_MAX}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="profile-handle">Handle</Label>
|
<Label htmlFor="profile-handle">Handle</Label>
|
||||||
{/* "@" sits inside the field as a fixed, non-editable prefix —
|
{/* "@" sits inside the field as a fixed, non-editable prefix —
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { Settings, X } from "lucide-react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { RelationshipResult, RelationshipState } from "@/lib/social";
|
import type { RelationshipResult, RelationshipState } from "@/lib/social";
|
||||||
import { ProfileActions } from "@/components/profile/profile-actions";
|
import { ProfileActions } from "@/components/profile/profile-actions";
|
||||||
|
import { DiscipleBadge } from "@/components/profile/disciple-badge";
|
||||||
|
import { MentionText } from "@/components/social/mention-text";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import {
|
import {
|
||||||
FollowListDrawer,
|
FollowListDrawer,
|
||||||
@@ -53,6 +55,8 @@ export function ProfileHeader({
|
|||||||
enableActions = false,
|
enableActions = false,
|
||||||
country,
|
country,
|
||||||
interactiveCounts = true,
|
interactiveCounts = true,
|
||||||
|
isDirectDisciple = false,
|
||||||
|
bio,
|
||||||
}: {
|
}: {
|
||||||
avatarURL: string | null;
|
avatarURL: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -81,6 +85,14 @@ export function ProfileHeader({
|
|||||||
* click target) and the follow-list drawer isn't mounted — used on a
|
* click target) and the follow-list drawer isn't mounted — used on a
|
||||||
* blocked profile, where the lists endpoint is gated server-side. */
|
* blocked profile, where the lists endpoint is gated server-side. */
|
||||||
interactiveCounts?: boolean;
|
interactiveCounts?: boolean;
|
||||||
|
/** Set when an admin has linked this account to the disciples directory —
|
||||||
|
* one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait
|
||||||
|
* on the avatar; tapping it explains the mark. */
|
||||||
|
isDirectDisciple?: boolean;
|
||||||
|
/** The profile's bio. Plain text — `@handle` mentions are linkified on
|
||||||
|
* render. Sits between the stat line and the action buttons, centred like
|
||||||
|
* the rest of the header. Omitted entirely when empty. */
|
||||||
|
bio?: string | null;
|
||||||
}) {
|
}) {
|
||||||
// Owner affordances ride on `editHref`; the follow-action row rides on
|
// Owner affordances ride on `editHref`; the follow-action row rides on
|
||||||
// `enableActions`. Mutually exclusive by construction.
|
// `enableActions`. Mutually exclusive by construction.
|
||||||
@@ -111,15 +123,22 @@ export function ProfileHeader({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
|
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
|
||||||
<UserAvatar
|
{/* Avatar, with Śrīla Prabhupāda's portrait tucked into the lower-right
|
||||||
src={avatarURL}
|
for his direct disciples (verification-tick placement). */}
|
||||||
name={name}
|
<div className="relative">
|
||||||
className="size-24 sm:size-28"
|
<UserAvatar
|
||||||
textClassName="text-3xl"
|
src={avatarURL}
|
||||||
interactive
|
name={name}
|
||||||
onClick={avatarURL ? () => setZoomOpen(true) : undefined}
|
className="size-24 sm:size-28"
|
||||||
ariaLabel={`View ${name}'s profile picture`}
|
textClassName="text-3xl"
|
||||||
/>
|
interactive
|
||||||
|
onClick={avatarURL ? () => setZoomOpen(true) : undefined}
|
||||||
|
ariaLabel={`View ${name}'s profile picture`}
|
||||||
|
/>
|
||||||
|
{isDirectDisciple ? (
|
||||||
|
<DiscipleBadge className="absolute -bottom-0.5 -right-0.5" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight">
|
<h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight">
|
||||||
@@ -158,6 +177,18 @@ export function ProfileHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bio — between the stats and the action buttons, centred to match the
|
||||||
|
rest of the header. `self-stretch` overrides the header's
|
||||||
|
`items-center` so the text box spans the full column and each
|
||||||
|
wrapped line centres against the same axis as the name and stats
|
||||||
|
(without it the box would shrink to its content and centre oddly). */}
|
||||||
|
{bio?.trim() ? (
|
||||||
|
<MentionText
|
||||||
|
text={bio.trim()}
|
||||||
|
className="self-stretch text-center text-sm sm:text-[15px] leading-relaxed text-foreground"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showOwnerControls ? (
|
{showOwnerControls ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,7 +64,9 @@ export function RewardCelebration({
|
|||||||
You've earned a reward!
|
You've earned a reward!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
|
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Gift } from "lucide-react";
|
import { Gift, Sparkles } from "lucide-react";
|
||||||
import type { SadhnaRewardGrant } from "@/lib/sadhna";
|
import { sadhna, type SadhnaRewardGrant } from "@/lib/sadhna";
|
||||||
import { useMounted } from "@/lib/use-mounted";
|
import { useMounted } from "@/lib/use-mounted";
|
||||||
|
import { RewardCelebration } from "@/components/sadhna/reward-celebration";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function RewardsGallery({
|
export function RewardsGallery({
|
||||||
collected,
|
collected,
|
||||||
@@ -27,6 +29,11 @@ export function RewardsGallery({
|
|||||||
nextRewardDate: string | null;
|
nextRewardDate: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [detail, setDetail] = useState<SadhnaRewardGrant | null>(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).
|
// De-dupe by reward (collected is newest-first, so the first wins).
|
||||||
const seen = new Set<string>();
|
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 (
|
return (
|
||||||
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
@@ -61,20 +81,42 @@ export function RewardsGallery({
|
|||||||
<button
|
<button
|
||||||
key={g.reward.key}
|
key={g.reward.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDetail(g)}
|
onClick={() =>
|
||||||
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"
|
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">
|
<div className="relative aspect-square w-full">
|
||||||
<Image
|
<Image
|
||||||
src={g.reward.image_url}
|
src={g.reward.image_url}
|
||||||
alt={g.reward.name}
|
alt={isWrapped(g) ? "" : g.reward.name}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 30vw, 120px"
|
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>
|
</div>
|
||||||
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -93,6 +135,15 @@ export function RewardsGallery({
|
|||||||
{detail ? (
|
{detail ? (
|
||||||
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
|
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
|
||||||
) : 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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,7 +196,11 @@ function RewardDetail({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-4 text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,13 +8,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
|
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
|
||||||
import { StreakFlame } from "@/components/sadhna/streak-flame";
|
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 { user, loading } = useAuth();
|
||||||
const authed = !loading && !!user;
|
const authed = !loading && !!user;
|
||||||
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
|
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
|
||||||
@@ -55,6 +65,35 @@ export function SadhnaStreakCTA() {
|
|||||||
? "One offering done — finish today"
|
? "One offering done — finish today"
|
||||||
: "Chant and post a quote to complete 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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/sadhna-streak"
|
href="/sadhna-streak"
|
||||||
|
|||||||
@@ -18,29 +18,10 @@ import { toast } from "sonner";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { comments as commentsApi, SocialError, type Comment } from "@/lib/social";
|
import { comments as commentsApi, SocialError, type Comment } from "@/lib/social";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
|
import { renderMentions } from "@/components/social/mention-text";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
import { CommentMenu } from "./comment-menu";
|
import { CommentMenu } from "./comment-menu";
|
||||||
|
|
||||||
// Split a comment body into text + clickable @handle links. The capturing
|
|
||||||
// group keeps the delimiters so they can be turned into profile links.
|
|
||||||
const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
|
|
||||||
function renderBody(text: string) {
|
|
||||||
return text.split(MENTION_TOKEN).map((part, i) => {
|
|
||||||
if (part.length > 1 && part[0] === "@") {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={i}
|
|
||||||
href={`/u/${part.slice(1)}`}
|
|
||||||
className="font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return part;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommentRow({
|
export function CommentRow({
|
||||||
comment,
|
comment,
|
||||||
viewerId,
|
viewerId,
|
||||||
@@ -172,7 +153,7 @@ export function CommentRow({
|
|||||||
@{comment.reply_to_handle}{" "}
|
@{comment.reply_to_handle}{" "}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{renderBody(comment.body)}
|
{renderMentions(comment.body)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Inline action row */}
|
{/* Inline action row */}
|
||||||
|
|||||||
@@ -382,7 +382,10 @@ export function PostCommentsDrawer({
|
|||||||
rows={1}
|
rows={1}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
placeholder={replyTo ? "Write a reply…" : "Add a comment…"}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,22 +33,30 @@ const SUGGEST_LIMIT = 8;
|
|||||||
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
|
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
|
||||||
|
|
||||||
type Mention = { start: number; query: string };
|
type Mention = { start: number; query: string };
|
||||||
type PanelPos = { left: number; bottom: number; width: number };
|
/** Exactly one of `top`/`bottom` is set — see the flip logic in detect(). */
|
||||||
|
type PanelPos = { left: number; width: number; top?: number; bottom?: number };
|
||||||
|
|
||||||
|
/** Keep in sync with the panel's `max-h-56` (14rem). Used to decide whether
|
||||||
|
* the suggestion list fits above the field or has to flip below it. */
|
||||||
|
const PANEL_MAX_H = 224;
|
||||||
|
|
||||||
export const MentionTextarea = forwardRef<
|
export const MentionTextarea = forwardRef<
|
||||||
HTMLTextAreaElement,
|
HTMLTextAreaElement,
|
||||||
{
|
{
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
/** Called on Enter (no Shift) when the suggestion list is closed. */
|
/** Called on Enter (no Shift) when the suggestion list is closed. Omit for
|
||||||
|
* multi-line fields — without it, Enter inserts a newline as usual. */
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
/** Forwarded to the <textarea> so a <Label htmlFor> can address it. */
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
>(function MentionTextarea(
|
>(function MentionTextarea(
|
||||||
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1 },
|
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1, id },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const innerRef = useRef<HTMLTextAreaElement>(null);
|
const innerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -61,6 +69,7 @@ export const MentionTextarea = forwardRef<
|
|||||||
const [pos, setPos] = useState<PanelPos | null>(null);
|
const [pos, setPos] = useState<PanelPos | null>(null);
|
||||||
const reqRef = useRef(0);
|
const reqRef = useRef(0);
|
||||||
const blurTimer = useRef<number | null>(null);
|
const blurTimer = useRef<number | null>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Detect the active @token from the text up to the caret.
|
// Detect the active @token from the text up to the caret.
|
||||||
function detect(el: HTMLTextAreaElement) {
|
function detect(el: HTMLTextAreaElement) {
|
||||||
@@ -70,14 +79,36 @@ export const MentionTextarea = forwardRef<
|
|||||||
setMention(null);
|
setMention(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMention({ start: caret - m[2].length - 1, query: m[2] });
|
const next = { start: caret - m[2].length - 1, query: m[2] };
|
||||||
setActive(0);
|
|
||||||
|
// Reposition unconditionally — the field grows as text wraps, so the panel
|
||||||
|
// has to stay pinned to it even when the token itself hasn't changed.
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
|
// Prefer opening ABOVE the field: that's what the comment composer needs
|
||||||
|
// (it sits at the bottom of a drawer, often above the on-screen keyboard).
|
||||||
|
// But flip BELOW when the field is too near the top of the viewport for the
|
||||||
|
// panel to fit — otherwise the list renders off-screen and the feature
|
||||||
|
// looks broken (e.g. the bio field high up on the profile editor).
|
||||||
|
const fitsAbove = r.top >= PANEL_MAX_H + 8;
|
||||||
setPos({
|
setPos({
|
||||||
left: r.left,
|
left: r.left,
|
||||||
bottom: window.innerHeight - r.top + 6,
|
|
||||||
width: r.width,
|
width: r.width,
|
||||||
|
...(fitsAbove
|
||||||
|
? { bottom: window.innerHeight - r.top + 6 }
|
||||||
|
: { top: r.bottom + 6 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bail out if the token is byte-for-byte what we already have. detect()
|
||||||
|
// also runs on keyup and click, so re-seeding an identical mention would:
|
||||||
|
// • reset `active` to 0 — which cancelled the ↑/↓ highlight the instant
|
||||||
|
// the key came back up, making arrow navigation look dead; and
|
||||||
|
// • hand the fetch effect a fresh object identity, re-requesting
|
||||||
|
// suggestions on every single keystroke.
|
||||||
|
if (mention && mention.start === next.start && mention.query === next.query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMention(next);
|
||||||
|
setActive(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch suggestions (debounced) whenever the active token changes.
|
// Fetch suggestions (debounced) whenever the active token changes.
|
||||||
@@ -120,6 +151,17 @@ export const MentionTextarea = forwardRef<
|
|||||||
|
|
||||||
const listOpen = !!mention && (loading || items.length > 0);
|
const listOpen = !!mention && (loading || items.length > 0);
|
||||||
|
|
||||||
|
// Keep the highlighted row visible: the panel caps at max-h-56, which holds
|
||||||
|
// ~5 of the 8 suggestions, so arrowing past the fifth would otherwise move
|
||||||
|
// the highlight out of sight. `block: "nearest"` scrolls the panel the
|
||||||
|
// minimum amount and never scrolls the page behind it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listOpen) return;
|
||||||
|
listRef.current
|
||||||
|
?.querySelector<HTMLElement>(`[data-idx="${active}"]`)
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [active, listOpen]);
|
||||||
|
|
||||||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (listOpen && items.length > 0) {
|
if (listOpen && items.length > 0) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
@@ -143,9 +185,12 @@ export const MentionTextarea = forwardRef<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
// Only hijack Enter when the caller actually wants submit-on-Enter.
|
||||||
|
// Without the `onSubmit` guard we'd swallow the keystroke for multi-line
|
||||||
|
// callers (e.g. the profile bio), leaving them unable to type a newline.
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit?.();
|
onSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +198,7 @@ export const MentionTextarea = forwardRef<
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<textarea
|
<textarea
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
|
id={id}
|
||||||
value={value}
|
value={value}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
@@ -175,13 +221,16 @@ export const MentionTextarea = forwardRef<
|
|||||||
{listOpen && pos && typeof document !== "undefined"
|
{listOpen && pos && typeof document !== "undefined"
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={listRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Mention suggestions"
|
aria-label="Mention suggestions"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: pos.left,
|
left: pos.left,
|
||||||
bottom: pos.bottom,
|
|
||||||
width: pos.width,
|
width: pos.width,
|
||||||
|
...(pos.top !== undefined
|
||||||
|
? { top: pos.top }
|
||||||
|
: { bottom: pos.bottom }),
|
||||||
}}
|
}}
|
||||||
// pointer-events-auto: the comments drawer is a modal vaul sheet
|
// pointer-events-auto: the comments drawer is a modal vaul sheet
|
||||||
// (body gets pointer-events:none); without this the list would be
|
// (body gets pointer-events:none); without this the list would be
|
||||||
@@ -194,6 +243,7 @@ export const MentionTextarea = forwardRef<
|
|||||||
key={u.id ?? u.handle}
|
key={u.id ?? u.handle}
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
|
data-idx={i}
|
||||||
aria-selected={i === active}
|
aria-selected={i === active}
|
||||||
// Keep textarea focus + cancel the pending blur-close so the
|
// Keep textarea focus + cancel the pending blur-close so the
|
||||||
// click registers before the list would otherwise close.
|
// click registers before the list would otherwise close.
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
type Comment,
|
type Comment,
|
||||||
type Post,
|
type Post,
|
||||||
} from "@/lib/social";
|
} 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 { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
@@ -62,6 +64,9 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
// on mobile native-share; sheet mount on desktop) so the Send icon
|
// on mobile native-share; sheet mount on desktop) so the Send icon
|
||||||
// can render a spinner while the user waits.
|
// can render a spinner while the user waits.
|
||||||
const [sharePending, setSharePending] = useState(false);
|
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
|
// Optimistic local mirror of post.viewer_has_reshared so the swap
|
||||||
// happens the instant the user taps Reshare. Reconciles to the
|
// happens the instant the user taps Reshare. Reconciles to the
|
||||||
// server's value via rollback if the API call fails.
|
// 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
|
// parallel, then navigator.share) and the desktop/no-native-share
|
||||||
// path (mount the custom SharePostSheet). Provider's openShareSheet
|
// path (mount the custom SharePostSheet). Provider's openShareSheet
|
||||||
// is always-resolves, so a single try/finally toggles the spinner.
|
// 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;
|
if (sharePending) return;
|
||||||
setSharePending(true);
|
setSharePending(true);
|
||||||
try {
|
try {
|
||||||
@@ -150,6 +164,12 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendGestures = useLongPress({
|
||||||
|
onClick: onSendClick,
|
||||||
|
onLongPress: onSendHold,
|
||||||
|
disabled: sharePending,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="flex items-center justify-between px-2">
|
||||||
@@ -197,8 +217,8 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
{showSend ? (
|
{showSend ? (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={sharePending ? "Preparing share link…" : "Send"}
|
label={sharePending ? "Preparing share link…" : "Send"}
|
||||||
onClick={onSendClick}
|
|
||||||
disabled={sharePending}
|
disabled={sharePending}
|
||||||
|
{...sendGestures}
|
||||||
icon={
|
icon={
|
||||||
sharePending ? (
|
sharePending ? (
|
||||||
<Loader2
|
<Loader2
|
||||||
@@ -270,29 +290,37 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
{/* SharePostSheet is mounted globally by SharePostProvider, so
|
{/* SharePostSheet is mounted globally by SharePostProvider, so
|
||||||
no per-card instance lives here. The provider's
|
no per-card instance lives here. The provider's
|
||||||
openShareSheet swaps the active post under the hood. */}
|
openShareSheet swaps the active post under the hood. */}
|
||||||
|
|
||||||
|
{sharingToChat && (
|
||||||
|
<ShareToChatDialog
|
||||||
|
entity={{ type: "post", id: post.id }}
|
||||||
|
onClose={() => setSharingToChat(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
|
||||||
icon,
|
icon,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
...buttonProps
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
/** Greys out the button and blocks clicks. Used by the Send button
|
/** Greys out the button and blocks clicks. Used by the Send button
|
||||||
* while the short URL is being resolved on the mobile native-share
|
* while the short URL is being resolved on the mobile native-share
|
||||||
* path so users can't fire navigator.share before the URL is
|
* path so users can't fire navigator.share before the URL is
|
||||||
* ready. */
|
* ready. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
/** onClick, plus the pointer handlers the Send button needs to tell a
|
||||||
|
* tap from a hold. */
|
||||||
|
} & React.ComponentProps<"button">) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
{...buttonProps}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
* posts (or imported data) still scroll to + highlight the text on click.
|
* posts (or imported data) still scroll to + highlight the text on click.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ExternalLink, Repeat2 } from "lucide-react";
|
import { ExternalLink, Repeat2 } from "lucide-react";
|
||||||
@@ -30,6 +31,7 @@ import { UserAvatar } from "@/components/ui/user-avatar";
|
|||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
import { PostActionsFooter } from "./post-actions-footer";
|
import { PostActionsFooter } from "./post-actions-footer";
|
||||||
import { PostCardMenu } from "./post-card-menu";
|
import { PostCardMenu } from "./post-card-menu";
|
||||||
|
import { PostImageLightbox } from "./post-image-lightbox";
|
||||||
|
|
||||||
export function PostCard({
|
export function PostCard({
|
||||||
post,
|
post,
|
||||||
@@ -38,6 +40,11 @@ export function PostCard({
|
|||||||
post: Post;
|
post: Post;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// Which attached photo the full-screen viewer is showing (null = closed).
|
||||||
|
// Owned here rather than inside the lightbox so the thumbnails can open it
|
||||||
|
// at their own index.
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const sourceHref =
|
const sourceHref =
|
||||||
hasHighlight(post.source_url) || !post.caption
|
hasHighlight(post.source_url) || !post.caption
|
||||||
? post.source_url
|
? post.source_url
|
||||||
@@ -125,10 +132,20 @@ export function PostCard({
|
|||||||
: "grid-cols-3"
|
: "grid-cols-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{post.images.map((img) => (
|
{post.images.map((img, i) => (
|
||||||
<div
|
// Each thumbnail opens the full-screen viewer at its own index.
|
||||||
|
// A <button> rather than a click-handled <div> so it's keyboard
|
||||||
|
// reachable and announced — the grid is the only way in.
|
||||||
|
<button
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
|
type="button"
|
||||||
|
onClick={() => setLightboxIndex(i)}
|
||||||
|
aria-label={
|
||||||
|
post.images.length > 1
|
||||||
|
? `View photo ${i + 1} of ${post.images.length}`
|
||||||
|
: "View photo"
|
||||||
|
}
|
||||||
|
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 cursor-pointer transition hover:ring-foreground/30 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
@@ -138,11 +155,19 @@ export function PostCard({
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<PostImageLightbox
|
||||||
|
images={post.images ?? []}
|
||||||
|
index={lightboxIndex}
|
||||||
|
caption={post.caption}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<Link
|
<Link
|
||||||
href={sourceHref}
|
href={sourceHref}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostImageLightbox — full-screen, swipeable viewer for a post's photos.
|
||||||
|
*
|
||||||
|
* Opened by tapping any image on a PostCard or PostScrollCard. Controlled by
|
||||||
|
* the caller: `index` is the photo on screen (null = closed), so the card owns
|
||||||
|
* the state and this stays presentation-only.
|
||||||
|
*
|
||||||
|
* Navigation is a native CSS scroll-snap strip — the same primitive the feed
|
||||||
|
* carousel uses — so swiping works on touch for free, and the ← / → keys and
|
||||||
|
* chevrons simply scroll that strip. The scroller is the single source of
|
||||||
|
* truth: buttons/keys never set `index` directly, they scroll, and the scroll
|
||||||
|
* handler reports the settled index back up. That one-way flow is what keeps a
|
||||||
|
* programmatic scroll from fighting the user's own gesture.
|
||||||
|
*
|
||||||
|
* Dismiss: tap the photo or the surrounding ring, the ✕, or press Escape. Taps
|
||||||
|
* are distinguished from swipes by useTapGuard, so navigating never closes.
|
||||||
|
*
|
||||||
|
* Rendered through a portal onto <body> so it escapes the feed's virtualised,
|
||||||
|
* transformed rows — an overlay nested inside one would be positioned against
|
||||||
|
* that transform and clipped by its stacking context, not the viewport.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||||
|
import type { PostImage } from "@/lib/social";
|
||||||
|
import { useTapGuard } from "./use-tap-guard";
|
||||||
|
|
||||||
|
export function PostImageLightbox({
|
||||||
|
images,
|
||||||
|
index,
|
||||||
|
caption,
|
||||||
|
onIndexChange,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
images: PostImage[];
|
||||||
|
/** Photo currently shown; null closes the viewer. */
|
||||||
|
index: number | null;
|
||||||
|
/** The post's caption, shown in a gradient scrim along the bottom.
|
||||||
|
* Clamped to three lines with an ellipsis so a long passage can't cover
|
||||||
|
* the photo. Omit (or pass empty) to hide the scrim entirely. */
|
||||||
|
caption?: string | null;
|
||||||
|
onIndexChange: (i: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const open = index !== null;
|
||||||
|
const count = images.length;
|
||||||
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
|
||||||
|
|
||||||
|
/** Scroll the strip to a slide. Clamped, not wrapped: the buttons and keys
|
||||||
|
* should be able to reach exactly what a swipe can, and you can't swipe
|
||||||
|
* past the end. */
|
||||||
|
const scrollToIndex = useCallback(
|
||||||
|
(i: number, behavior: ScrollBehavior) => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const target = Math.max(0, Math.min(i, count - 1));
|
||||||
|
el.scrollTo({ left: target * el.offsetWidth, behavior });
|
||||||
|
},
|
||||||
|
[count],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
if (index === null) return;
|
||||||
|
scrollToIndex(index - 1, "smooth");
|
||||||
|
}, [index, scrollToIndex]);
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
if (index === null) return;
|
||||||
|
scrollToIndex(index + 1, "smooth");
|
||||||
|
}, [index, scrollToIndex]);
|
||||||
|
|
||||||
|
// Jump to the tapped photo when the viewer opens. Deliberately keyed on
|
||||||
|
// `open` only — re-running on every `index` change would yank the strip back
|
||||||
|
// mid-swipe, since scrolling is what updates `index` in the first place.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
scrollToIndex(index ?? 0, "auto");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
else if (e.key === "ArrowLeft") goPrev();
|
||||||
|
else if (e.key === "ArrowRight") goNext();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose, goPrev, goNext]);
|
||||||
|
|
||||||
|
// Lock body scroll while open so the feed underneath doesn't drift.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const w = el.offsetWidth;
|
||||||
|
if (w === 0) return;
|
||||||
|
const i = Math.round(el.scrollLeft / w);
|
||||||
|
if (i !== index && i >= 0 && i < count) onIndexChange(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open || typeof document === "undefined") return null;
|
||||||
|
if (!images[index]) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={count > 1 ? `Photo ${index + 1} of ${count}` : "Photo"}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0"
|
||||||
|
>
|
||||||
|
{/* The strip sits inset from the edges, leaving a backdrop ring that
|
||||||
|
also closes. `absolute inset-*` rather than padding because padding
|
||||||
|
does NOT inset an absolutely-positioned `fill` child. */}
|
||||||
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
|
onScroll={onScroll}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Swiping between photos must never dismiss the viewer; only a
|
||||||
|
// genuine tap falls through to the backdrop's onClose.
|
||||||
|
if (!wasTap(e)) e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="absolute inset-4 sm:inset-12 flex overflow-x-auto snap-x snap-mandatory overscroll-contain [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<div
|
||||||
|
key={img.id}
|
||||||
|
className="relative h-full w-full shrink-0 snap-center"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={img.url}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="100vw"
|
||||||
|
className="object-contain"
|
||||||
|
unoptimized
|
||||||
|
// next/image defaults to loading="lazy". That's right for the
|
||||||
|
// feed, but wrong here: this only mounts once already open, so
|
||||||
|
// every slide is on (or one swipe from) screen. Left lazy, the
|
||||||
|
// browser waits for the intersection heuristic before it even
|
||||||
|
// starts — which is the pause you'd see on open despite the
|
||||||
|
// bytes already sitting in cache from the card.
|
||||||
|
loading="eager"
|
||||||
|
// The slide being viewed outranks its neighbours in the queue.
|
||||||
|
fetchPriority={i === index ? "high" : "auto"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="fixed top-4 right-4 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{count > 1 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goPrev();
|
||||||
|
}}
|
||||||
|
aria-label="Previous photo"
|
||||||
|
className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === count - 1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goNext();
|
||||||
|
}}
|
||||||
|
aria-label="Next photo"
|
||||||
|
className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Counter sits top-left so it never collides with the caption
|
||||||
|
scrim along the bottom. */}
|
||||||
|
<span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums">
|
||||||
|
{index + 1} / {count}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Dot indicators, mirroring the in-feed carousel. */}
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-1.5 flex items-center justify-center gap-1.5">
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<span
|
||||||
|
key={img.id}
|
||||||
|
aria-hidden
|
||||||
|
className={`size-1.5 rounded-full transition-all ${
|
||||||
|
i === index ? "bg-white" : "bg-white/40"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Caption scrim. `pointer-events-none` so it never intercepts a tap
|
||||||
|
meant for the backdrop, and the gradient fades upward so the
|
||||||
|
photo's lower edge stays readable rather than cut by a hard band. */}
|
||||||
|
{caption?.trim() ? (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-8">
|
||||||
|
<p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3">
|
||||||
|
“{caption.trim()}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ import { useAuth } from "@/components/auth/auth-provider";
|
|||||||
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
|
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
|
||||||
import { PostActionsFooter } from "./post-actions-footer";
|
import { PostActionsFooter } from "./post-actions-footer";
|
||||||
import { PostCardMenu } from "./post-card-menu";
|
import { PostCardMenu } from "./post-card-menu";
|
||||||
|
import { PostImageLightbox } from "./post-image-lightbox";
|
||||||
|
import { useTapGuard } from "./use-tap-guard";
|
||||||
import { FeedFollowButton } from "./feed-follow-button";
|
import { FeedFollowButton } from "./feed-follow-button";
|
||||||
import { UpdateBackgroundModal } from "../update-background-modal";
|
import { UpdateBackgroundModal } from "../update-background-modal";
|
||||||
import { MentionsDrawer } from "../mentions-drawer";
|
import { MentionsDrawer } from "../mentions-drawer";
|
||||||
@@ -68,6 +70,8 @@ export function PostScrollCard({
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAuthor = !!user && user.id === post.user_id;
|
const isAuthor = !!user && user.id === post.user_id;
|
||||||
const [bgModal, setBgModal] = useState(false);
|
const [bgModal, setBgModal] = useState(false);
|
||||||
|
// Which attached photo the full-screen viewer is showing (null = closed).
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [mentionsOpen, setMentionsOpen] = useState(false);
|
const [mentionsOpen, setMentionsOpen] = useState(false);
|
||||||
// Live mention count — seeded from the hydrated post, adjusted when the
|
// Live mention count — seeded from the hydrated post, adjusted when the
|
||||||
@@ -176,7 +180,7 @@ export function PostScrollCard({
|
|||||||
{/* Media — image carousel or gradient-with-quote */}
|
{/* Media — image carousel or gradient-with-quote */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{hasImages ? (
|
{hasImages ? (
|
||||||
<ImageCarousel images={images} />
|
<ImageCarousel images={images} onOpen={setLightboxIndex} />
|
||||||
) : (
|
) : (
|
||||||
<QuotePanel post={post} />
|
<QuotePanel post={post} />
|
||||||
)}
|
)}
|
||||||
@@ -225,14 +229,33 @@ export function PostScrollCard({
|
|||||||
onClose={() => setMentionsOpen(false)}
|
onClose={() => setMentionsOpen(false)}
|
||||||
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
|
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PostImageLightbox
|
||||||
|
images={images}
|
||||||
|
index={lightboxIndex}
|
||||||
|
caption={post.caption}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Pieces ──────────────────────────────────────────────────────────────
|
// ─── Pieces ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ImageCarousel({ images }: { images: PostImage[] }) {
|
function ImageCarousel({
|
||||||
|
images,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
images: PostImage[];
|
||||||
|
/** Tapping a slide opens the full-screen viewer at that index. */
|
||||||
|
onOpen: (index: number) => void;
|
||||||
|
}) {
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Swiping the carousel and tapping a photo have to stay independent — the
|
||||||
|
// strip scrolls natively, and browsers still fire a click after some swipes.
|
||||||
|
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
|
||||||
|
|
||||||
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
@@ -242,8 +265,14 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (images.length === 1) {
|
if (images.length === 1) {
|
||||||
|
// Single image: no scroller, so every click is unambiguously a tap.
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-square bg-muted">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(0)}
|
||||||
|
aria-label="View photo"
|
||||||
|
className="relative block w-full aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src={images[0].url}
|
src={images[0].url}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -252,13 +281,14 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex overflow-x-auto snap-x snap-mandatory",
|
"flex overflow-x-auto snap-x snap-mandatory",
|
||||||
@@ -266,11 +296,17 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{images.map((img, i) => (
|
{images.map((img, i) => (
|
||||||
<div
|
<button
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="relative w-full shrink-0 snap-start aspect-square bg-muted"
|
type="button"
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!wasTap(e)) return; // that was a swipe — let it scroll
|
||||||
|
onOpen(i);
|
||||||
|
}}
|
||||||
aria-roledescription="slide"
|
aria-roledescription="slide"
|
||||||
aria-label={`${i + 1} of ${images.length}`}
|
aria-label={`View photo ${i + 1} of ${images.length}`}
|
||||||
|
className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
@@ -280,7 +316,7 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
|
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -38,8 +38,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { Post } from "@/lib/social";
|
import type { Post } from "@/lib/social";
|
||||||
import { createShortLink } from "@/lib/share";
|
import { buildPostShareText } from "@/lib/post-share-text";
|
||||||
import { structuredReferenceForPost } from "@/lib/source-reference";
|
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { SharePostSheet } from "./share-post-sheet";
|
import { SharePostSheet } from "./share-post-sheet";
|
||||||
@@ -131,30 +130,17 @@ export function SharePostProvider({
|
|||||||
// it.
|
// it.
|
||||||
// 3. On any non-AbortError, fall back to the custom sheet.
|
// 3. On any non-AbortError, fall back to the custom sheet.
|
||||||
try {
|
try {
|
||||||
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
|
|
||||||
const hasImage = !!post.images?.[0];
|
const hasImage = !!post.images?.[0];
|
||||||
const [code, imageFile] = await Promise.all([
|
const [share, imageFile] = await Promise.all([
|
||||||
createShortLink(longPath),
|
buildPostShareText(post, handle),
|
||||||
hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null),
|
hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
const origin = window.location.origin;
|
if (!share) {
|
||||||
const url = code
|
setActivePost(post);
|
||||||
? new URL(`/share/${code}`, origin).toString()
|
return;
|
||||||
: new URL(longPath, origin).toString();
|
}
|
||||||
|
|
||||||
const ref = structuredReferenceForPost(
|
const payload: ShareData = { title: share.title, text: share.text };
|
||||||
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 };
|
|
||||||
// Only attach files when the platform actually accepts them.
|
// Only attach files when the platform actually accepts them.
|
||||||
// canShare returns false on Chrome desktop / Firefox even
|
// canShare returns false on Chrome desktop / Firefox even
|
||||||
// though navigator.share itself exists, and we want to fail
|
// though navigator.share itself exists, and we want to fail
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
|
MessageCircle,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Send,
|
Send,
|
||||||
Share2,
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
|
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 { bookmarks, SocialError } from "@/lib/social";
|
||||||
import {
|
import {
|
||||||
KautilyaIcon,
|
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useCopyFeedback,
|
useCopyFeedback,
|
||||||
type Position,
|
type Position,
|
||||||
@@ -25,6 +25,8 @@ import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
|
|||||||
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
|
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
|
||||||
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
|
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
|
||||||
import { ComposePost } from "./selection-toolbar/compose-post";
|
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 { useAuth } from "./auth/auth-provider";
|
||||||
import { useAuthGate } from "./auth/auth-gate";
|
import { useAuthGate } from "./auth/auth-gate";
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ import { useAuthGate } from "./auth/auth-gate";
|
|||||||
|
|
||||||
export function TextSelectionToolbar() {
|
export function TextSelectionToolbar() {
|
||||||
const [selectedText, setSelectedText] = useState("");
|
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 [toolbarPos, setToolbarPos] = useState<Position | null>(null);
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
|
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
|
||||||
@@ -327,7 +331,20 @@ export function TextSelectionToolbar() {
|
|||||||
return null;
|
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 ──────────────────────────────────────────────────
|
// ─── Action handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -337,6 +354,21 @@ export function TextSelectionToolbar() {
|
|||||||
setShowCollections(true);
|
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() {
|
async function onPost() {
|
||||||
if (!(await requireAuth())) return;
|
if (!(await requireAuth())) return;
|
||||||
if (isMobile) saveAndClearSelection();
|
if (isMobile) saveAndClearSelection();
|
||||||
@@ -418,14 +450,11 @@ export function TextSelectionToolbar() {
|
|||||||
|
|
||||||
const isAuthed = !!user;
|
const isAuthed = !!user;
|
||||||
|
|
||||||
const askAction: ToolbarAction = {
|
const chatAction: ToolbarAction = {
|
||||||
key: "ask",
|
key: "chat",
|
||||||
label: "Ask Kautilya",
|
label: "Chat",
|
||||||
icon: <KautilyaIcon className="size-3.5" />,
|
icon: <MessageCircle className="size-3.5" />,
|
||||||
onClick: () => {
|
onClick: onChat,
|
||||||
// TODO: Implement Ask Kautilya
|
|
||||||
finishAction();
|
|
||||||
},
|
|
||||||
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
|
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,7 +474,7 @@ export function TextSelectionToolbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const authedToolbarActions: ToolbarAction[] = [
|
const authedToolbarActions: ToolbarAction[] = [
|
||||||
askAction,
|
chatAction,
|
||||||
{
|
{
|
||||||
key: "post",
|
key: "post",
|
||||||
label: "Post",
|
label: "Post",
|
||||||
@@ -466,7 +495,6 @@ export function TextSelectionToolbar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const publicToolbarActions: ToolbarAction[] = [
|
const publicToolbarActions: ToolbarAction[] = [
|
||||||
askAction,
|
|
||||||
{
|
{
|
||||||
key: "copy",
|
key: "copy",
|
||||||
label: "Copy",
|
label: "Copy",
|
||||||
@@ -515,6 +543,17 @@ export function TextSelectionToolbar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const publicMoreItems: MoreItem[] = [
|
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",
|
key: "share-as-post",
|
||||||
label: "Share as Post",
|
label: "Share as Post",
|
||||||
@@ -601,6 +640,8 @@ export function TextSelectionToolbar() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
selectedText={selectedText}
|
selectedText={selectedText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{shareToChat}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
// A curated, broadly-useful set — enough to feel like a picker without
|
// A curated, broadly-useful set — enough to feel like a picker without
|
||||||
// shipping a full emoji database. Grouped for easy scanning.
|
// 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",
|
label: "Smileys",
|
||||||
emojis: [
|
emojis: [
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { getVaniOfTheDay, type Vani } from "@/lib/vani";
|
import { getVaniOfTheDay, type Vani } from "@/lib/vani";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Quote-marks glyph (svgrepo). Inherits color via `currentColor`. */
|
/** Quote-marks glyph (svgrepo). Inherits color via `currentColor`. */
|
||||||
function QuotesIcon({ className }: { className?: string }) {
|
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);
|
const [vani, setVani] = useState<Vani | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,11 +58,11 @@ export function VaniOfTheDayCTA() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={vani.href}
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium text-primary">Vani of the day</p>
|
<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">
|
<p className="mt-0.5 truncate text-base font-semibold text-foreground leading-snug">
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export type User = {
|
|||||||
* the "Change password" modal and the "Set a password" flow.
|
* the "Change password" modal and the "Set a password" flow.
|
||||||
*/
|
*/
|
||||||
has_password: boolean;
|
has_password: boolean;
|
||||||
|
/** True when an admin has linked this account to the disciples directory —
|
||||||
|
* one of Śrīla Prabhupāda's own initiated disciples. Admin-set only. */
|
||||||
|
is_direct_disciple?: boolean;
|
||||||
|
/** Short self-introduction (≤200 chars), plain text with `@handle`
|
||||||
|
* mentions linkified at render time. null when unset. */
|
||||||
|
bio: string | null;
|
||||||
/** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null
|
/** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null
|
||||||
* when unknown. Captured best-effort at signup; user-editable. */
|
* when unknown. Captured best-effort at signup; user-editable. */
|
||||||
country: string | null;
|
country: string | null;
|
||||||
@@ -222,6 +228,8 @@ export const auth = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
handle?: string;
|
handle?: string;
|
||||||
|
/** Plain text, max 200 chars; "" clears it. */
|
||||||
|
bio?: string;
|
||||||
country?: string | null;
|
country?: string | null;
|
||||||
sampradaya?: string;
|
sampradaya?: string;
|
||||||
gender?: string | null;
|
gender?: string | null;
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
};
|
||||||
@@ -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
Reference in New Issue
Block a user