implement paywall (#161)

* implement core foundation

* inject deps

* fix incorrect migration

* tail migration

* use transaction for migration

* fix: inject deps for tests

* cleanup billing management for admin

* upgrade stripe sdk to v85

* set price env variables

* cleanup billing management

* allow multiple dev windows

* fix: settings scroll

* feat: show nice video thumbnail in listview

* feat: implement freemium restrictions

* remove unnecessary comments

* refactor

* docs

* format

* tweak network settings better hierarchy
This commit was merged in pull request #161.
This commit is contained in:
Arjun Patel
2026-04-14 15:18:32 -07:00
committed by GitHub
parent aff18d82db
commit 67826b92c0
44 changed files with 2197 additions and 153 deletions
+51 -16
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
import { toast } from "sonner";
import { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
import { QuotaExceededError, useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
import { isUsageExhausted, useInvalidateNetworkUsage, useNetworkUsage } from "@/hooks/use-network-usage";
import { useRecorder } from "@/features/compose/use-recorder";
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
import { particlePath, parseParticlePath } from "@/lib/particle-path";
@@ -70,12 +71,17 @@ export function ComposeOverlay({
const userId = useAuthStore((s) => s.user?.id);
const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle();
const { data: usage } = useNetworkUsage(networkId);
const invalidateUsage = useInvalidateNetworkUsage();
const quotaExhausted = isUsageExhausted(usage);
// Refs for synchronous reads in keyboard handlers
const stepRef = useRef(step);
const recordStartRef = useRef(0);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const quotaExhaustedRef = useRef(quotaExhausted);
quotaExhaustedRef.current = quotaExhausted;
const recordingSourceRef = useRef(recordingSource);
recordingSourceRef.current = recordingSource;
@@ -88,7 +94,12 @@ export function ComposeOverlay({
useEffect(() => {
onActiveChange?.(step !== "idle");
onStepChange?.(step);
}, [step, onActiveChange, onStepChange]);
// Refresh quota when the overlay activates — user is about to send, so
// we want the most accurate count before the client-side gate kicks in.
if (step !== "idle") {
void invalidateUsage(networkId);
}
}, [step, onActiveChange, onStepChange, invalidateUsage, networkId]);
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
for (const a of items) {
@@ -334,12 +345,25 @@ export function ComposeOverlay({
],
);
const handleQuotaError = useCallback((err: unknown): boolean => {
if (err instanceof QuotaExceededError) {
toast.error("Daily message limit reached. Upgrade to Pro to keep sending.");
cancel();
return true;
}
return false;
}, [cancel]);
// Reply mode: create particle directly under targetPath
const onSubmitReply = useEffectEvent(async () => {
if (!targetPath || !userId || stepRef.current === "submitting") return;
setStepSync("submitting");
await createChildParticle(targetPath);
cancel();
try {
await createChildParticle(targetPath);
cancel();
} catch (err) {
if (!handleQuotaError(err)) throw err;
}
});
// New stream mode: create stream + first child
@@ -348,21 +372,25 @@ export function ComposeOverlay({
if (!userId || stepRef.current === "submitting") return;
setStepSync("submitting");
const streamId = await createStream.mutateAsync({
networkId,
properties: {
name: streamName,
},
createdByHumanId: userId,
visibleTo,
});
try {
const streamId = await createStream.mutateAsync({
networkId,
properties: {
name: streamName,
},
createdByHumanId: userId,
visibleTo,
});
const streamChildrenPath = particlePath(networkId, [streamId]);
await createChildParticle(streamChildrenPath);
const streamChildrenPath = particlePath(networkId, [streamId]);
await createChildParticle(streamChildrenPath);
cancel();
cancel();
} catch (err) {
if (!handleQuotaError(err)) throw err;
}
},
[networkId, userId, createParticle, createChildParticle, cancel],
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
);
// --- Keyboard handling ---
@@ -397,6 +425,13 @@ export function ComposeOverlay({
}
break;
}
if (quotaExhaustedRef.current) {
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") {
e.preventDefault();
toast.info("Daily message limit reached. Upgrade to Pro to keep sending.");
}
break;
}
if (e.key === "`" && !e.repeat) {
e.preventDefault();
recordStartRef.current = Date.now();
@@ -0,0 +1,94 @@
import { useNavigate } from "react-router-dom";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { useNetworkUsage } from "@/hooks/use-network-usage";
import { useIsNetworkAdmin, useNetwork } from "@/hooks/use-networks";
interface ComposeQuotaIndicatorProps {
networkId: string;
}
const SHOW_PROGRESS_AT_FRACTION = 0.7;
/**
* Surfaces freemium quota state near compose:
* - Nothing below 70% used (avoid nagging).
* - A subtle progress pill between 70% and the limit.
* - A locked banner with an upgrade CTA once the limit is hit.
*
* Pro networks and any network still loading usage render nothing.
*/
export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps) {
const navigate = useNavigate();
const { data: usage } = useNetworkUsage(networkId);
const isAdmin = useIsNetworkAdmin(networkId);
const network = useNetwork(networkId);
if (!usage || usage.limit == null) return null;
const fraction = usage.used / usage.limit;
const exhausted = usage.used >= usage.limit;
if (exhausted) {
return (
<div className="pointer-events-auto flex max-w-md flex-col items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 text-center shadow-lg backdrop-blur">
<div className="text-sm font-medium">
{isAdmin
? `You've reached today's ${usage.limit}-message limit`
: `This network reached today's ${usage.limit}-message limit`}
</div>
<div className="text-xs text-muted-foreground">
Resets {formatResetRelative(usage.reset_at)} ({formatResetAbsolute(usage.reset_at)})
</div>
{isAdmin ? (
<Button
size="sm"
onClick={() => navigate(`/${networkId}/settings?section=billing`)}
>
Upgrade to Pro
</Button>
) : (
<div className="text-xs text-muted-foreground">
Ask{" "}
<span className="font-medium text-foreground">
{network?.admin_human.email_prefix ?? "your admin"}
</span>{" "}
to upgrade to Pro
</div>
)}
</div>
);
}
if (fraction < SHOW_PROGRESS_AT_FRACTION) return null;
return (
<div
className="pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur"
title={`Resets ${formatResetRelative(usage.reset_at)} at ${formatResetAbsolute(usage.reset_at)}`}
>
<span className="tabular-nums">
{usage.used}/{usage.limit} today
</span>
<Progress value={fraction * 100} className="h-1 w-24" />
</div>
);
}
function formatResetRelative(resetAt: Date): string {
const now = new Date();
const diffMs = resetAt.getTime() - now.getTime();
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
if (hours < 1) return "soon";
if (hours === 1) return "in 1 hour";
return `in ${hours} hours`;
}
function formatResetAbsolute(resetAt: Date): string {
// Shows the user their local wall-clock time for the UTC-midnight reset,
// so a user in UTC-8 sees "4:00 PM" instead of a relative hint alone.
return resetAt.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
+339
View File
@@ -0,0 +1,339 @@
import { useState } from "react";
import { ExternalLink } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { Muted } from "@/components/ui/typography";
import { cn } from "@/lib/utils";
import {
useCreateCheckoutSession,
useCreatePortalSession,
useNetworkBilling,
} from "@/hooks/use-billing";
import { useNetworkUsage } from "@/hooks/use-network-usage";
import { useIsNetworkAdmin } from "@/hooks/use-networks";
import type { BillingCadence, BillingStatus } from "@/api/types";
function formatCents(cents: number): string {
if (cents % 100 === 0) return `$${cents / 100}`;
return `$${(cents / 100).toFixed(2)}`;
}
function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
});
}
function PlanStatusBadge({ status }: { status: BillingStatus["plan_status"] }) {
if (status === "past_due")
return <Badge variant="destructive">Past due</Badge>;
if (status === "canceled") return <Badge variant="secondary">Canceled</Badge>;
if (status === "trialing") return <Badge variant="secondary">Trialing</Badge>;
return null;
}
function InfoRow({
label,
value,
}: {
label: React.ReactNode;
value: React.ReactNode;
}) {
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<Muted className="text-sm">{label}</Muted>
<div className="flex-1" />
<div className="text-sm">{value}</div>
</div>
);
}
function CadenceOption({
value,
label,
perSeatCents,
billedNote,
saveBadge,
selected,
}: {
value: BillingCadence;
label: string;
perSeatCents: number;
billedNote: string;
saveBadge?: string;
selected: boolean;
}) {
return (
<Label
htmlFor={`cadence-${value}`}
className={cn(
"hover:bg-accent flex w-full cursor-pointer items-center gap-3 px-4 py-3 font-normal transition-colors",
selected && "bg-accent/50",
)}
>
<RadioGroupItem id={`cadence-${value}`} value={value} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label}</p>
{saveBadge && <Badge>{saveBadge}</Badge>}
</div>
<Muted className="text-xs">{billedNote}</Muted>
</div>
<div className="shrink-0 text-right">
<p className="text-sm font-medium">{formatCents(perSeatCents)}</p>
<Muted className="text-xs">per seat / mo</Muted>
</div>
</Label>
);
}
function formatResetLocal(resetAt: Date): string {
const now = new Date();
const diffMs = resetAt.getTime() - now.getTime();
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
const absolute = resetAt.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
if (hours < 1) return `soon (${absolute})`;
if (hours === 1) return `in 1 hour (${absolute})`;
return `in ${hours} hours (${absolute})`;
}
/**
* Read-only plan + quota summary, sourced from `/usage` (member-accessible).
* The `/billing` endpoint is admin-gated, so we can't use it for the
* everyone-visible summary.
*/
function PlanSummary({ networkId }: { networkId: string }) {
const { data: usage } = useNetworkUsage(networkId);
if (!usage) return null;
const isPro = usage.plan === "pro";
return (
<>
<InfoRow
label="Plan"
value={
<div className="flex items-center gap-2">
<span>{isPro ? "Llink Pro" : "Llink Free"}</span>
<Badge variant={isPro ? "default" : "secondary"}>
{isPro ? "Pro" : "Free"}
</Badge>
</div>
}
/>
{!isPro && (
<>
<Separator className="mx-4" />
<InfoRow
label="Today's messages"
value={
usage && usage.limit != null ? (
<div className="flex flex-col items-end">
<span className="tabular-nums">
{usage.used} / {usage.limit}
</span>
<Muted className="text-xs">
Resets {formatResetLocal(usage.reset_at)}
</Muted>
</div>
) : (
<Muted className="text-sm"></Muted>
)
}
/>
</>
)}
</>
);
}
function FreeBilling({
networkId,
billing,
}: {
networkId: string;
billing: BillingStatus;
}) {
const createCheckout = useCreateCheckoutSession(networkId);
const [cadence, setCadence] = useState<BillingCadence>("annual");
const handleUpgrade = () => {
createCheckout.mutate(cadence, {
onSuccess: ({ url }) => window.electronLink.openExternal(url),
onError: (err) => toast.error(err.message || "Failed to start checkout"),
});
};
const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12);
const savingsPct = Math.round(
(1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100,
);
return (
<>
<RadioGroup
value={cadence}
onValueChange={(v) => setCadence(v as BillingCadence)}
className="gap-0"
>
<CadenceOption
value="annual"
label="Annual"
perSeatCents={annualPerSeatMonthlyCents}
billedNote="Billed annually"
saveBadge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
selected={cadence === "annual"}
/>
<Separator className="mx-4" />
<CadenceOption
value="monthly"
label="Monthly"
perSeatCents={billing.price_monthly_cents}
billedNote="Billed monthly · cancel anytime"
selected={cadence === "monthly"}
/>
</RadioGroup>
<div className="px-4 py-3">
<Button
className="w-full"
onClick={handleUpgrade}
disabled={createCheckout.isPending}
>
{createCheckout.isPending ? "Opening Stripe..." : "Upgrade to Pro"}
</Button>
</div>
</>
);
}
function ProBilling({
networkId,
billing,
}: {
networkId: string;
billing: BillingStatus;
}) {
const createPortal = useCreatePortalSession(networkId);
const handleManage = () => {
createPortal.mutate(undefined, {
onSuccess: ({ url }) => window.electronLink.openExternal(url),
onError: (err) =>
toast.error(err.message || "Failed to open billing portal"),
});
};
const cadenceLabel = billing.cadence === "annual" ? "Annual" : "Monthly";
const perSeatCents =
billing.cadence === "annual"
? Math.round(billing.price_annual_cents / 12)
: billing.price_monthly_cents;
const renewal = billing.current_period_end
? formatDate(billing.current_period_end)
: null;
return (
<>
{billing.cancel_at_period_end && renewal && (
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
Your subscription is set to downgrade to Free on {renewal}.
</div>
)}
{billing.plan_status === "past_due" && (
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
Your last payment failed. Update your payment method to keep Pro
active.
</div>
)}
<InfoRow
label="Billing"
value={
<div className="flex items-center gap-2">
<span>{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}</span>
<PlanStatusBadge status={billing.plan_status} />
</div>
}
/>
<Separator className="mx-4" />
<InfoRow label="Seats" value={billing.seats} />
{renewal && (
<>
<Separator className="mx-4" />
<InfoRow
label={billing.cancel_at_period_end ? "Ends" : "Renews"}
value={renewal}
/>
</>
)}
<div className="px-4 py-3">
<Button
variant="outline"
className="w-full"
onClick={handleManage}
disabled={createPortal.isPending}
>
<ExternalLink className="mr-2 size-3.5" />
{createPortal.isPending
? "Opening Stripe..."
: "Manage subscription"}
</Button>
</div>
</>
);
}
/**
* Unified billing section. Shows the plan + usage summary to every member,
* and the admin-only management controls (upgrade / portal) below.
*
* `/billing` is admin-gated, so the management controls are the only part
* that depends on it — members rely on `/usage` for the summary.
*/
export function BillingSection({ networkId }: { networkId: string }) {
const isAdmin = useIsNetworkAdmin(networkId);
return (
<>
<PlanSummary networkId={networkId} />
{isAdmin && (
<>
<Separator className="mx-4" />
<AdminBillingControls networkId={networkId} />
</>
)}
</>
);
}
function AdminBillingControls({ networkId }: { networkId: string }) {
const { data: billing, isLoading, error } = useNetworkBilling(networkId);
if (isLoading || !billing) {
return (
<div className="px-4 py-3">
<Muted className="text-sm">Loading billing...</Muted>
</div>
);
}
if (error) {
return (
<div className="px-4 py-3">
<Muted className="text-sm">Failed to load billing.</Muted>
</div>
);
}
if (billing.plan === "pro") {
return <ProBilling networkId={networkId} billing={billing} />;
}
return <FreeBilling networkId={networkId} billing={billing} />;
}
+7 -1
View File
@@ -5,6 +5,7 @@ import { particlePath } from "@/lib/particle-path";
import { ParticleListView } from "@/features/particles/particle-list-view";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { ComposeOverlay } from "./compose/compose-overlay";
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStreamParticles } from "@/hooks/use-stream-particles";
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
@@ -50,7 +51,7 @@ export default function NetworkRoot() {
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Top bar — stays in place */}
<div className="flex shrink-0 items-center justify-between p-1 border-b">
<div className="flex shrink-0 items-center p-1 border-b">
<Tabs
value={statusTab}
onValueChange={(v) => setStatusTab(v === "closed" ? "closed" : "open")}
@@ -75,6 +76,11 @@ export default function NetworkRoot() {
</div>
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
{!composeActive && (
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
<ComposeQuotaIndicator networkId={networkId!} />
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
<div className="pointer-events-auto">
<NetworkRootControls />
+122 -45
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Mail, Shield, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from "lucide-react";
import { toast } from "sonner";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@@ -17,15 +17,10 @@ import {
useRevokeInvitation,
} from "@/hooks/use-invitations";
import { useAuthStore } from "@/stores/auth-store";
import { BillingSection } from "@/features/network-billing";
import type { Human } from "@/api/types";
function MemberRow({
human,
isAdmin,
}: {
human: Human;
isAdmin: boolean;
}) {
function MemberRow({ human, isAdmin }: { human: Human; isAdmin: boolean }) {
const initials = human.email_prefix.slice(0, 2).toUpperCase();
return (
@@ -73,7 +68,7 @@ function InviteForm({ networkId }: { networkId: string }) {
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
<Input
type="email"
placeholder="Email address"
placeholder="[email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
@@ -111,7 +106,7 @@ function PendingInvitationRow({
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-10 items-center justify-center">
<span className="text-muted-foreground flex size-8 items-center justify-center">
<Mail className="size-4" />
</span>
<div className="min-w-0 flex-1">
@@ -131,33 +126,63 @@ function PendingInvitationRow({
);
}
function SettingsGroup({
function SectionHeader({
icon,
title,
children,
description,
trailing,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
description?: string;
trailing?: React.ReactNode;
}) {
return (
<div>
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
{title}
</p>
<div>{children}</div>
<div className="flex items-start gap-3 px-4 pb-2 pt-6">
<span className="text-muted-foreground mt-0.5 flex size-4 items-center justify-center">
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{trailing}
</div>
{description && <Muted className="text-xs">{description}</Muted>}
</div>
</div>
);
}
function Section({ children }: { children: React.ReactNode }) {
return (
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
{children}
</section>
);
}
export default function NetworkSettingsPage() {
const navigate = useNavigate();
const { networkId } = useParams<{ networkId: string }>();
const [searchParams] = useSearchParams();
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const { data: invitations } = useNetworkInvitations(networkId!);
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.id === network?.admin_human.id;
const billingRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (searchParams.get("section") === "billing") {
billingRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [searchParams]);
const networkName = network?.name ?? "Network";
const memberCount = network?.humans.length ?? 0;
const pendingCount = invitations?.length ?? 0;
const networkInitials = networkName.slice(0, 2).toUpperCase();
return (
<div className="flex h-screen flex-col">
@@ -171,12 +196,38 @@ export default function NetworkSettingsPage() {
>
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium">{networkName}</span>
<span className="text-sm font-medium">Settings</span>
<div className="flex-1" />
</div>
<ScrollArea className="flex-1">
<SettingsGroup title="Members">
<ScrollArea className="min-h-0 flex-1">
<div className="flex items-center gap-3 px-4 pb-4 pt-6">
<Avatar size="lg">
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{networkInitials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-semibold">{networkName}</p>
<Muted className="text-xs">
{memberCount} {memberCount === 1 ? "member" : "members"}
{isAdmin ? " · You're an admin" : ""}
</Muted>
</div>
</div>
<Section>
<SectionHeader
icon={<Users className="size-4" />}
title="Members"
description="People with access to this network."
trailing={
<Badge variant="secondary" className="tabular-nums">
{memberCount}
</Badge>
}
/>
<Separator />
{network?.humans.map((human, index) => (
<div key={human.id}>
<MemberRow
@@ -188,39 +239,65 @@ export default function NetworkSettingsPage() {
)}
</div>
))}
</SettingsGroup>
<Separator className="mt-4" />
</Section>
{isAdmin && network && (
<>
<SettingsGroup title="Invite">
<InviteForm networkId={networkId!} />
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="Pending Invitations">
{invitations && invitations.length > 0 ? (
invitations.map((inv, index) => (
<Section>
<SectionHeader
icon={<Mail className="size-4" />}
title="Invitations"
description="Invite teammates by email. They'll get a link to join."
trailing={
pendingCount > 0 ? (
<Badge variant="secondary" className="tabular-nums">
{pendingCount} pending
</Badge>
) : undefined
}
/>
<Separator />
<InviteForm networkId={networkId!} />
{pendingCount > 0 && (
<>
<Separator />
<div className="px-4 pb-1 pt-3">
<Muted className="text-xs font-medium uppercase tracking-wider">
Pending
</Muted>
</div>
{invitations!.map((inv, index) => (
<div key={inv.email}>
<PendingInvitationRow
email={inv.email}
networkId={networkId!}
/>
{index < invitations.length - 1 && (
{index < invitations!.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))
) : (
<p className="text-muted-foreground px-4 py-3 text-sm">
No pending invitations
</p>
)}
</SettingsGroup>
</>
))}
</>
)}
</Section>
)}
<div ref={billingRef}>
<Section>
<SectionHeader
icon={<CreditCard className="size-4" />}
title="Billing"
description={
isAdmin
? "Manage your plan, seats, and payment."
: "Your network's current plan and usage."
}
/>
<Separator />
<BillingSection networkId={networkId!} />
</Section>
</div>
<div className="h-6" />
</ScrollArea>
</div>
);
@@ -28,8 +28,38 @@ import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/t
import type { StreamParticle } from "@/hooks/use-stream-particles";
import { useNetwork } from "@/hooks/use-networks";
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
function VideoThumbnail({
objectId,
isUnseen,
}: {
objectId: string;
isUnseen: boolean;
}) {
const { data: url } = useDownloadUrl(objectId);
return (
<div
className={cn(
"size-8 shrink-0 overflow-hidden rounded-md bg-muted",
isUnseen && "ring-2 ring-primary",
)}
>
{url && (
<video
// Seek ~15 frames in so we skip any initial black/fade-in frames
src={`${url}#t=0.5`}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
/>
)}
</div>
);
}
function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) {
@@ -156,6 +186,14 @@ const StreamRow = memo(function StreamRow({
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
const videoThumbObjectId =
latestChild &&
!isParticleDeleted(latestChild) &&
latestChild.type === "media" &&
latestChild.properties.mime_type.startsWith("video/")
? latestChild.properties.object_id
: null;
return (
<div
role="button"
@@ -173,13 +211,15 @@ const StreamRow = memo(function StreamRow({
{shortcutKey}
</kbd>
)}
<Avatar
className={cn(isUnseen && "ring-2 ring-primary")}
>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
{videoThumbObjectId ? (
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
) : (
<Avatar className={cn(isUnseen && "ring-2 ring-primary")}>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p
@@ -237,7 +237,7 @@ export default function AudioVideoSettingsPage() {
<div className="flex-1" />
</div>
<ScrollArea className="flex-1">
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 px-5 py-5">
{!permissionGranted && (
<div className="bg-muted/40 flex items-start justify-between gap-3 rounded-md border px-3 py-2.5">