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
+40
View File
@@ -2,18 +2,23 @@ import { appConfig } from "@/config/env";
import { useSessionStore } from "@/stores/session-store";
import type { z } from "zod";
import {
BillingStatusSchema,
CheckoutSessionResponseSchema,
DepotObjectSchema,
GetLivekitTokenResponseSchema,
HumanSchema,
ListInvitationsResponseSchema,
ListNetworksResponseSchema,
NetworkSchema,
NetworkUsageSchema,
PortalSessionResponseSchema,
PrepareUploadResponseSchema,
SignInResponseSchema,
} from "./types";
import type {
AcceptInvitationRequest,
AddMembersRequest,
BillingCadence,
CreateNetworkRequest,
PrepareUploadRequest,
RequestCodeRequest,
@@ -216,6 +221,41 @@ class ApiClient {
async getLivekitToken(networkId: string, streamId: string) {
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
}
// --- Billing (network admin only) ---
async getNetworkBilling(networkId: string) {
return this.request(
BillingStatusSchema,
"GET",
`/networks/${networkId}/billing`,
);
}
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
return this.request(
CheckoutSessionResponseSchema,
"POST",
`/networks/${networkId}/billing/checkout-session`,
{ cadence },
);
}
async createPortalSession(networkId: string) {
return this.request(
PortalSessionResponseSchema,
"POST",
`/networks/${networkId}/billing/portal-session`,
);
}
async getNetworkUsage(networkId: string) {
return this.request(
NetworkUsageSchema,
"GET",
`/networks/${networkId}/usage`,
);
}
}
export const apiClient = new ApiClient({
+50
View File
@@ -262,3 +262,53 @@ export const SignInResponseSchema = z.object({
token: z.string(),
});
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
// --- Billing types ---
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
export const NetworkPlanSchema = z.enum(["free", "pro"]);
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
export const BillingPlanStatusSchema = z.enum([
"active",
"trialing",
"past_due",
"canceled",
"incomplete",
"incomplete_expired",
"unpaid",
]);
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
export const BillingStatusSchema = z.object({
plan: NetworkPlanSchema,
plan_status: BillingPlanStatusSchema,
cadence: BillingCadenceSchema.nullable(),
seats: z.number().int(),
current_period_end: z.coerce.date().nullable(),
cancel_at_period_end: z.boolean(),
price_monthly_cents: z.number().int(),
price_annual_cents: z.number().int(),
});
export type BillingStatus = z.infer<typeof BillingStatusSchema>;
export const CheckoutSessionResponseSchema = z.object({
url: z.string().url(),
});
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
export const PortalSessionResponseSchema = z.object({
url: z.string().url(),
});
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
export const NetworkUsageSchema = z.object({
plan: NetworkPlanSchema,
used: z.number().int().nonnegative(),
limit: z.number().int().nonnegative().nullable(),
reset_at: z.coerce.date(),
});
export type NetworkUsage = z.infer<typeof NetworkUsageSchema>;
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+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">
+29
View File
@@ -0,0 +1,29 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import type { BillingCadence } from "@/api/types";
export function useNetworkBilling(networkId: string | undefined) {
return useQuery({
queryKey: ["network-billing", networkId],
queryFn: () => apiClient.getNetworkBilling(networkId!),
enabled: !!networkId,
// Refetch on window focus so the UI catches up after the user returns
// from Stripe Checkout (webhook may land a second or two later).
// FIX: doesn't work with electron
refetchOnWindowFocus: true,
refetchInterval: 10000
});
}
export function useCreateCheckoutSession(networkId: string) {
return useMutation({
mutationFn: (cadence: BillingCadence) =>
apiClient.createCheckoutSession(networkId, cadence),
});
}
export function useCreatePortalSession(networkId: string) {
return useMutation({
mutationFn: () => apiClient.createPortalSession(networkId),
});
}
+45 -5
View File
@@ -1,7 +1,26 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
import {
isUsageExhausted,
networkUsageQueryKey,
useBumpNetworkUsage,
useInvalidateNetworkUsage,
} from "./use-network-usage";
/**
* Thrown when a free-plan network attempts to create a non-container particle
* after hitting its daily message limit. Callers should surface an upgrade
* prompt; compose UI should also disable triggers proactively via
* `useNetworkUsage` rather than relying on this throw.
*/
export class QuotaExceededError extends Error {
constructor(public readonly networkId: string) {
super("Daily message limit reached");
this.name = "QuotaExceededError";
}
}
interface CreateParticleParams<T extends ParticleType = ParticleType> {
// Path to which the new particle will be added as a child
@@ -12,16 +31,37 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
}
export function useCreateParticle() {
const qc = useQueryClient();
const bumpUsage = useBumpNetworkUsage();
const invalidateUsage = useInvalidateNetworkUsage();
return useMutation({
mutationFn: async (params: CreateParticleParams) => {
const { networkId } = parseParticlePath(params.path);
// Containers aren't counted server-side, so we don't block them.
if (!CONTAINER_TYPES.has(params.type)) {
const cached = qc.getQueryData<NetworkUsage>(networkUsageQueryKey(networkId));
if (isUsageExhausted(cached)) {
throw new QuotaExceededError(networkId);
}
}
const collectionPath = toFirestoreChildrenPath(params.path);
return await createParticle(
const result = await createParticle(
collectionPath,
params.type,
params.properties,
params.createdByHumanId,
);
}
if (!CONTAINER_TYPES.has(params.type)) {
bumpUsage(networkId);
void invalidateUsage(networkId);
}
return result;
},
});
}
+57
View File
@@ -0,0 +1,57 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { apiClient } from "@/api/client";
import type { NetworkUsage } from "@/api/types";
export const networkUsageQueryKey = (networkId: string | undefined) =>
["network-usage", networkId] as const;
export function useNetworkUsage(networkId: string | undefined) {
return useQuery({
queryKey: networkUsageQueryKey(networkId),
queryFn: () => apiClient.getNetworkUsage(networkId!),
enabled: !!networkId,
// Refetch whenever a consumer mounts (billing settings, compose indicator)
// so users land on fresh quota state without listener wiring.
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
}
/**
* Returns a callback that invalidates the usage query for a network.
* Callers: own-send success path, inbound-particle listener.
*/
export function useInvalidateNetworkUsage() {
const qc = useQueryClient();
return useCallback(
(networkId: string) =>
qc.invalidateQueries({ queryKey: networkUsageQueryKey(networkId) }),
[qc],
);
}
/**
* Optimistic bump of the cached `used` count. The worker-written truth is
* reconciled on the next invalidation/refetch.
*/
export function useBumpNetworkUsage() {
const qc = useQueryClient();
return useCallback(
(networkId: string) => {
qc.setQueryData<NetworkUsage>(networkUsageQueryKey(networkId), (prev) =>
prev ? { ...prev, used: prev.used + 1 } : prev,
);
},
[qc],
);
}
/**
* True iff the network is on the free plan and has exhausted today's quota.
*/
export function isUsageExhausted(usage: NetworkUsage | undefined): boolean {
if (!usage) return false;
if (usage.limit == null) return false;
return usage.used >= usage.limit;
}
+8
View File
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import { useAuthStore } from "@/stores/auth-store";
export function useNetworks() {
return useQuery({
@@ -12,3 +13,10 @@ export function useNetwork(networkId: string) {
const { data: networks } = useNetworks();
return networks?.find((n) => n.id === networkId) || null;
}
export function useIsNetworkAdmin(networkId: string): boolean {
const network = useNetwork(networkId);
const userId = useAuthStore((s) => s.user?.id);
if (!network || !userId) return false;
return network.admin_human.id === userId;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, us
import { useAuthStore } from "@/stores/auth-store";
import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
// --- Playback reducer (ID-based) ---
+11 -1
View File
@@ -25,11 +25,21 @@ if (process.platform === 'darwin' && !app.isPackaged) {
app.dock?.setIcon(path.join(__dirname, '../../assets/icon.png'));
}
// In dev, `LLINK_PROFILE=foo yarn start` spins up a second instance with an
// isolated userData dir so it can coexist with the default one (separate auth,
// cookies, leveldb locks).
const devProfile = !app.isPackaged ? process.env.LLINK_PROFILE : undefined;
if (devProfile) {
app.setPath('userData', `${app.getPath('userData')}-${devProfile}`);
}
// Single-instance lock: on Windows/Linux, clicking a llink:// URL launches a new
// process. The lock makes the losing instance quit and fires `second-instance` on
// the primary, so we focus the existing window instead of spawning a duplicate.
// macOS uses `open-url` instead and doesn't need this, but the lock is harmless.
if (!app.requestSingleInstanceLock()) {
// Skip the lock when running a named dev profile — those instances are meant to
// run alongside the default one.
if (!devProfile && !app.requestSingleInstanceLock()) {
app.quit();
}