- {/* Attach menu — photo / video / document / voice. */}
-
-
- {busy ? (
-
- ) : (
-
- )}
-
-
- imageInputRef.current?.click()}>
-
- Photo
-
- videoInputRef.current?.click()}>
-
- Video
-
- documentInputRef.current?.click()}>
-
- Document
-
- void startRecording()}>
-
- Voice note
-
-
-
+ {/* Attach menu — photo / video / document / voice. Beta-gated:
+ hidden entirely for accounts not opted into 'chat_media'. */}
+ {chatMediaEnabled && (
+
+
+ {busy ? (
+
+ ) : (
+
+ )}
+
+
+ imageInputRef.current?.click()}>
+
+ Photo
+
+ videoInputRef.current?.click()}>
+
+ Video
+
+ documentInputRef.current?.click()}>
+
+ Document
+
+ void startRecording()}>
+
+ Voice note
+
+
+
+ )}
+
Type '/' for quick replies
)}
diff --git a/src/hooks/use-beta-feature.ts b/src/hooks/use-beta-feature.ts
new file mode 100644
index 0000000..bb128ea
--- /dev/null
+++ b/src/hooks/use-beta-feature.ts
@@ -0,0 +1,20 @@
+"use client";
+
+import { useAuth } from "@/hooks/use-auth";
+import { hasBetaFeature, type BetaFeature } from "@/lib/auth/beta-features";
+
+/**
+ * Boolean gate for a per-profile beta feature flag (see
+ * `@/lib/auth/beta-features`). Mirrors `useCan`: returns `false` while the
+ * profile is still loading so a not-yet-opted-in user never sees a flash
+ * of beta UI during the initial load window.
+ *
+ * Example:
+ * const chatMedia = useBetaFeature(CHAT_MEDIA_BETA);
+ * {chatMedia && }
+ */
+export function useBetaFeature(feature: BetaFeature): boolean {
+ const { profileLoading, profile } = useAuth();
+ if (profileLoading || !profile) return false;
+ return hasBetaFeature(profile.beta_features, feature);
+}
diff --git a/src/lib/auth/beta-features.test.ts b/src/lib/auth/beta-features.test.ts
new file mode 100644
index 0000000..b8a374a
--- /dev/null
+++ b/src/lib/auth/beta-features.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { CHAT_MEDIA_BETA, hasBetaFeature } from "./beta-features";
+
+describe("hasBetaFeature", () => {
+ it("is true when the feature key is present", () => {
+ expect(hasBetaFeature([CHAT_MEDIA_BETA], CHAT_MEDIA_BETA)).toBe(true);
+ expect(hasBetaFeature(["flows", CHAT_MEDIA_BETA], CHAT_MEDIA_BETA)).toBe(true);
+ });
+
+ it("is false when the key is absent", () => {
+ expect(hasBetaFeature(["flows"], CHAT_MEDIA_BETA)).toBe(false);
+ expect(hasBetaFeature([], CHAT_MEDIA_BETA)).toBe(false);
+ });
+
+ it("is false (not throwing) for null / undefined lists", () => {
+ expect(hasBetaFeature(null, CHAT_MEDIA_BETA)).toBe(false);
+ expect(hasBetaFeature(undefined, CHAT_MEDIA_BETA)).toBe(false);
+ });
+});
diff --git a/src/lib/auth/beta-features.ts b/src/lib/auth/beta-features.ts
new file mode 100644
index 0000000..7da0f58
--- /dev/null
+++ b/src/lib/auth/beta-features.ts
@@ -0,0 +1,31 @@
+/**
+ * Beta feature flags, stored per-profile in `profiles.beta_features`
+ * (a `TEXT[]`, migration 011). A feature is "on" for an account when its
+ * key is present in that array; the owner opts in by editing the column
+ * in Supabase Studio:
+ *
+ * UPDATE profiles
+ * SET beta_features = array_append(beta_features, 'chat_media')
+ * WHERE email = 'someone@example.com';
+ *
+ * This module is the single source of truth for the keys + the membership
+ * check so the client hook (`useBetaFeature`) and the server routes gate
+ * on exactly the same logic.
+ */
+
+/** Agent-initiated media sends in the inbox composer (issue #213). */
+export const CHAT_MEDIA_BETA = "chat_media";
+
+export type BetaFeature = typeof CHAT_MEDIA_BETA;
+
+/**
+ * Whether an opted-in feature list contains `feature`. Pure + null-safe so
+ * both the client (reading `profile.beta_features`) and the server
+ * (reading the row from Supabase) share one implementation.
+ */
+export function hasBetaFeature(
+ betaFeatures: readonly string[] | null | undefined,
+ feature: BetaFeature,
+): boolean {
+ return betaFeatures?.includes(feature) ?? false;
+}