Compare commits
8
Commits
dev
..
feat/secutils
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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>
|
||||||
|
|||||||
@@ -350,6 +350,8 @@ function messageFor(n: AppNotification): string {
|
|||||||
return "replied to your comment.";
|
return "replied to your comment.";
|
||||||
case "comment_mention":
|
case "comment_mention":
|
||||||
return "mentioned you in a comment.";
|
return "mentioned you in a comment.";
|
||||||
|
case "disciple_joined":
|
||||||
|
return "— a direct disciple of Śrīla Prabhupāda — has joined Prabhupāda Says.";
|
||||||
case "admin_message":
|
case "admin_message":
|
||||||
return n.body ?? "sent you a message.";
|
return n.body ?? "sent you a message.";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ import {
|
|||||||
pushSupported,
|
pushSupported,
|
||||||
} from "@/lib/push";
|
} from "@/lib/push";
|
||||||
|
|
||||||
const EVENTS: { key: PrefEvent; label: string; description: string }[] = [
|
const EVENTS: {
|
||||||
|
key: PrefEvent;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
/** Channels this event can actually be delivered on. Omit for all three.
|
||||||
|
* A cell outside this set renders as "—" rather than a live toggle, so we
|
||||||
|
* never show a switch that silently does nothing. */
|
||||||
|
channels?: PrefChannel[];
|
||||||
|
}[] = [
|
||||||
{
|
{
|
||||||
key: "follows",
|
key: "follows",
|
||||||
label: "Follows",
|
label: "Follows",
|
||||||
@@ -42,6 +50,14 @@ const EVENTS: { key: PrefEvent; label: string; description: string }[] = [
|
|||||||
label: "Sādhana reminders",
|
label: "Sādhana reminders",
|
||||||
description: "Daily nudges to chant your rounds and share a verse.",
|
description: "Daily nudges to chant your rounds and share a verse.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "community",
|
||||||
|
label: "Community",
|
||||||
|
description:
|
||||||
|
"Rare announcements — such as a direct disciple of Śrīla Prabhupāda joining.",
|
||||||
|
// Announcements are in-app/push only; we deliberately don't email them.
|
||||||
|
channels: ["in_app", "push"],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHANNELS: { key: PrefChannel; label: string }[] = [
|
const CHANNELS: { key: PrefChannel; label: string }[] = [
|
||||||
@@ -73,7 +89,9 @@ export function NotificationMatrix() {
|
|||||||
|
|
||||||
async function toggle(event: PrefEvent, channel: PrefChannel) {
|
async function toggle(event: PrefEvent, channel: PrefChannel) {
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
const next = !prefs[event][channel];
|
// Default-on when the key is absent: prefs are stored as opt-outs, and a
|
||||||
|
// BFF that predates this category simply omits it.
|
||||||
|
const next = !(prefs[event]?.[channel] ?? true);
|
||||||
setBusyCell(`${event}/${channel}`);
|
setBusyCell(`${event}/${channel}`);
|
||||||
try {
|
try {
|
||||||
// Turning a push cell ON only helps if this device can receive push —
|
// Turning a push cell ON only helps if this device can receive push —
|
||||||
@@ -132,23 +150,34 @@ export function NotificationMatrix() {
|
|||||||
{event.description}
|
{event.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{CHANNELS.map((channel) => (
|
{CHANNELS.map((channel) => {
|
||||||
<div
|
const deliverable =
|
||||||
key={channel.key}
|
!event.channels || event.channels.includes(channel.key);
|
||||||
className="flex justify-center border-t border-border py-3"
|
return (
|
||||||
>
|
<div
|
||||||
{prefs ? (
|
key={channel.key}
|
||||||
<ToggleSwitch
|
className="flex justify-center border-t border-border py-3"
|
||||||
checked={prefs[event.key][channel.key]}
|
>
|
||||||
disabled={busyCell === `${event.key}/${channel.key}`}
|
{!deliverable ? (
|
||||||
onToggle={() => toggle(event.key, channel.key)}
|
<span
|
||||||
label={`${event.label} — ${channel.label}`}
|
className="text-xs text-muted-foreground"
|
||||||
/>
|
title={`${event.label} isn't delivered by ${channel.label.toLowerCase()}`}
|
||||||
) : (
|
>
|
||||||
<span className="text-xs text-muted-foreground">…</span>
|
—
|
||||||
)}
|
</span>
|
||||||
</div>
|
) : prefs ? (
|
||||||
))}
|
<ToggleSwitch
|
||||||
|
checked={prefs[event.key]?.[channel.key] ?? true}
|
||||||
|
disabled={busyCell === `${event.key}/${channel.key}`}
|
||||||
|
onToggle={() => toggle(event.key, channel.key)}
|
||||||
|
label={`${event.label} — ${channel.label}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,13 +5,18 @@
|
|||||||
* WebSocket connection to our Soketi (Pusher-compatible) server so the
|
* WebSocket connection to our Soketi (Pusher-compatible) server so the
|
||||||
* bell badge + a toast update in realtime.
|
* bell badge + a toast update in realtime.
|
||||||
*
|
*
|
||||||
* Transport: subscribes to the PUBLIC channel `notifications.{userId}` and
|
* Transport: subscribes to the PRIVATE channel `private-notifications.{userId}`
|
||||||
* binds the `notification` event the BFF triggers on each follow-request /
|
* and binds the `notification` event the BFF triggers on each follow-request /
|
||||||
* follow-accept. The event is a lightweight nudge — we bump the unread
|
* follow-accept. The event is a lightweight nudge — we bump the unread
|
||||||
* count locally and toast — while the authoritative list/count still come
|
* count locally and toast — while the authoritative list/count still come
|
||||||
* from the REST endpoints. (Public channel for now; once Soketi App auth
|
* from the REST endpoints.
|
||||||
* is configured, switch to a `private-` channel + a /broadcasting/auth
|
*
|
||||||
* endpoint — only the channel name and a Pusher `authEndpoint` change.)
|
* The channel is private so a subscription requires a signature the BFF only
|
||||||
|
* issues to that user; on the old public channel anyone who knew a user's id
|
||||||
|
* could listen in. Signing goes through `channelAuthorizer` (see
|
||||||
|
* lib/realtime.ts). The BFF still publishes to the old public channel too, so
|
||||||
|
* a service-worker-cached bundle running this file's previous version keeps
|
||||||
|
* working until it updates.
|
||||||
*
|
*
|
||||||
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
|
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
|
||||||
* realtime silently disables and the bell falls back to its fetched count.
|
* realtime silently disables and the bell falls back to its fetched count.
|
||||||
@@ -26,6 +31,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { channelAuthorizer } from "@/lib/realtime";
|
||||||
import { feed, notifications } from "@/lib/social";
|
import { feed, notifications } from "@/lib/social";
|
||||||
import { sadhna } from "@/lib/sadhna";
|
import { sadhna } from "@/lib/sadhna";
|
||||||
|
|
||||||
@@ -70,6 +76,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:
|
||||||
@@ -143,7 +151,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 +168,16 @@ 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);
|
||||||
|
});
|
||||||
channel.bind("notification", (data: IncomingEvent) => {
|
channel.bind("notification", (data: IncomingEvent) => {
|
||||||
setUnread((u) => u + 1);
|
setUnread((u) => u + 1);
|
||||||
setLastEventAt(Date.now());
|
setLastEventAt(Date.now());
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ export type YearGroup = {
|
|||||||
|
|
||||||
export type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
|
export type SuffixFilter = "all" | "Dasa" | "Devi Dasi";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A disciple who has a platform account, linked by an admin.
|
||||||
|
*
|
||||||
|
* The disciple records live in Mongo while accounts live in Postgres, so the
|
||||||
|
* link is resolved server-side by the BFF and handed over as this flat pair.
|
||||||
|
*/
|
||||||
|
export type DiscipleLink = {
|
||||||
|
discipleId: string;
|
||||||
|
handle: string;
|
||||||
|
name: string;
|
||||||
|
avatarURL: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// --- BFF response types (snake_case from Go API) ---
|
// --- BFF response types (snake_case from Go API) ---
|
||||||
|
|
||||||
type BffDisciple = {
|
type BffDisciple = {
|
||||||
@@ -76,6 +89,42 @@ function mapDisciple(d: BffDisciple): DiscipleSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Linked platform accounts ---
|
||||||
|
|
||||||
|
type BffDiscipleLink = {
|
||||||
|
disciple_id: string;
|
||||||
|
handle: string;
|
||||||
|
name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get every disciple who has a platform account.
|
||||||
|
*
|
||||||
|
* Deliberately unpaged — only Śrīla Prabhupāda's own disciples are ever
|
||||||
|
* linked, so this is a few dozen rows at most and the browser fetches the
|
||||||
|
* whole set once to highlight the matching cards.
|
||||||
|
*
|
||||||
|
* Fails soft: the highlight is decorative, so a BFF hiccup (or a rolling
|
||||||
|
* deploy where the frontend is ahead of the API) must not take the whole
|
||||||
|
* directory page down with it. `bff` already maps 404 → null; here we also
|
||||||
|
* swallow 5xx and network errors and fall back to "no highlights".
|
||||||
|
*/
|
||||||
|
export async function getDiscipleLinks(): Promise<DiscipleLink[]> {
|
||||||
|
try {
|
||||||
|
const links = await bffList<BffDiscipleLink>("/disciples/linked");
|
||||||
|
return links.map((l) => ({
|
||||||
|
discipleId: l.disciple_id,
|
||||||
|
handle: l.handle,
|
||||||
|
name: l.name,
|
||||||
|
avatarURL: l.avatar_url ?? null,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("getDiscipleLinks failed; rendering without highlights", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Count ---
|
// --- Count ---
|
||||||
|
|
||||||
/** Get total number of disciples in the collection. */
|
/** Get total number of disciples in the collection. */
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Realtime channel authorization.
|
||||||
|
*
|
||||||
|
* Our Soketi channels are `private-`/`presence-` prefixed, so Soketi refuses a
|
||||||
|
* subscription without a signature. Only the BFF holds the app secret, so the
|
||||||
|
* client asks it to sign each subscription at
|
||||||
|
* POST /api/v1/broadcasting/auth — which first checks the session cookie
|
||||||
|
* identifies someone allowed on that channel.
|
||||||
|
*
|
||||||
|
* We supply a `customHandler` rather than pusher-js's built-in `authEndpoint`
|
||||||
|
* because that built-in uses XMLHttpRequest without setting
|
||||||
|
* `withCredentials`, so it would never send our httpOnly session cookie
|
||||||
|
* cross-origin (prabhupadasays.org → api.prabhupadasays.org) and every
|
||||||
|
* subscription would 401. A plain fetch with `credentials: "include"` matches
|
||||||
|
* how the rest of lib/ talks to the BFF.
|
||||||
|
*
|
||||||
|
* The endpoint answers with a BARE {auth, channel_data} body rather than the
|
||||||
|
* usual {success, data} envelope, because pusher-js reads `auth` off the top
|
||||||
|
* level of the response.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChannelAuthorizationHandler } from "pusher-js";
|
||||||
|
|
||||||
|
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs private/presence subscriptions. Shared by every realtime consumer, so
|
||||||
|
* new channel families need no client-side change — only a rule on the BFF.
|
||||||
|
*/
|
||||||
|
export const channelAuthorizer: ChannelAuthorizationHandler = (
|
||||||
|
{ socketId, channelName },
|
||||||
|
callback,
|
||||||
|
) => {
|
||||||
|
fetch(`${BFF_BASE}/api/v1/broadcasting/auth`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ socket_id: socketId, channel_name: channelName }),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
// 403 is a normal outcome (not a participant / not your channel), so
|
||||||
|
// carry the status rather than presenting it as a network fault.
|
||||||
|
throw new Error(
|
||||||
|
`channel authorization failed for ${channelName}: ${res.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await res.json()) as { auth: string; channel_data?: string };
|
||||||
|
})
|
||||||
|
.then((data) => callback(null, data))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
callback(err instanceof Error ? err : new Error(String(err)), null);
|
||||||
|
});
|
||||||
|
};
|
||||||
+11
-1
@@ -182,6 +182,9 @@ export type PublicProfile = {
|
|||||||
handle: string;
|
handle: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
/** Short self-introduction (≤200 chars), plain text with `@handle`
|
||||||
|
* mentions linkified at render time. null when unset. */
|
||||||
|
bio: string | null;
|
||||||
joined_at: string;
|
joined_at: string;
|
||||||
/** ISO-3166-1 alpha-2 account country, or null when unknown. */
|
/** ISO-3166-1 alpha-2 account country, or null when unknown. */
|
||||||
country: string | null;
|
country: string | null;
|
||||||
@@ -192,6 +195,9 @@ export type PublicProfile = {
|
|||||||
follower_count: number;
|
follower_count: number;
|
||||||
following_count: number;
|
following_count: number;
|
||||||
relationship: RelationshipState | null;
|
relationship: RelationshipState | null;
|
||||||
|
/** True when an admin has linked this account to the disciples directory —
|
||||||
|
* i.e. one of Śrīla Prabhupāda's own initiated disciples. */
|
||||||
|
is_direct_disciple?: boolean;
|
||||||
public_collections: Collection[];
|
public_collections: Collection[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -698,7 +704,11 @@ export const notifications = {
|
|||||||
|
|
||||||
// ─── Notification preferences ──────────────────────────────────────────
|
// ─── Notification preferences ──────────────────────────────────────────
|
||||||
|
|
||||||
export type PrefEvent = "follows" | "post_interactions" | "sadhna_reminders";
|
export type PrefEvent =
|
||||||
|
| "follows"
|
||||||
|
| "post_interactions"
|
||||||
|
| "sadhna_reminders"
|
||||||
|
| "community";
|
||||||
export type PrefChannel = "in_app" | "email" | "push";
|
export type PrefChannel = "in_app" | "email" | "push";
|
||||||
/** event → channel → enabled, as resolved by the BFF (defaults on). */
|
/** event → channel → enabled, as resolved by the BFF (defaults on). */
|
||||||
export type NotificationPrefs = Record<PrefEvent, Record<PrefChannel, boolean>>;
|
export type NotificationPrefs = Record<PrefEvent, Record<PrefChannel, boolean>>;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Reference in New Issue
Block a user