feat: implement freemium restrictions

This commit is contained in:
talksik
2026-04-14 14:42:53 -07:00
parent 1cd22cd4e2
commit 185d83401f
20 changed files with 636 additions and 46 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();