Merge pull request #263 from ArnasDon/feat/chat-media-ga

feat(inbox): graduate chat media send to GA (remove beta flag)
This commit is contained in:
Arnas Donauskas 2026-06-18 11:40:46 +03:00 committed by GitHub
commit 60466d14b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 71 additions and 174 deletions

View File

@ -21,7 +21,6 @@ import {
} from '@/lib/rate-limit' } from '@/lib/rate-limit'
import type { MessageTemplate } from '@/types' import type { MessageTemplate } from '@/types'
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard' import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
import { hasBetaFeature, CHAT_MEDIA_BETA } from '@/lib/auth/beta-features'
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@ -52,7 +51,7 @@ export async function POST(request: Request) {
// returned nothing for teammates who didn't author the row. // returned nothing for teammates who didn't author the row.
const { data: profile } = await supabase const { data: profile } = await supabase
.from('profiles') .from('profiles')
.select('account_id, beta_features') .select('account_id')
.eq('user_id', user.id) .eq('user_id', user.id)
.maybeSingle() .maybeSingle()
const accountId = profile?.account_id as string | undefined const accountId = profile?.account_id as string | undefined
@ -120,17 +119,6 @@ export async function POST(request: Request) {
) )
} }
// Media sends are beta-gated (issue #213). Text + templates stay
// available to everyone; only accounts opted into 'chat_media' may
// send media — mirrors the client gate so the endpoint can't be
// driven ahead of the rollout.
if (isMediaKind && !hasBetaFeature(profile?.beta_features, CHAT_MEDIA_BETA)) {
return NextResponse.json(
{ error: 'Media messages are not enabled for your account.' },
{ status: 403 }
)
}
// Meta caps media captions at 1024 chars; reject before the upload is // Meta caps media captions at 1024 chars; reject before the upload is
// wasted at the Meta call. (Audio carries no caption — see meta-api.) // wasted at the Meta call. (Audio carries no caption — see meta-api.)
if ( if (

View File

@ -28,8 +28,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useCan } from "@/hooks/use-can"; import { useCan } from "@/hooks/use-can";
import { useBetaFeature } from "@/hooks/use-beta-feature";
import { CHAT_MEDIA_BETA } from "@/lib/auth/beta-features";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -162,10 +160,6 @@ export function MessageComposer({
const readOnly = !canSend; const readOnly = !canSend;
// Media (like free-form text) is only allowed inside the 24h window. // Media (like free-form text) is only allowed inside the 24h window.
const inputsDisabled = readOnly || sessionExpired; const inputsDisabled = readOnly || sessionExpired;
// Beta-gated: only accounts opted into 'chat_media' see the attach
// menu / voice recorder. Everyone else gets the original text + template
// composer with no visible change.
const chatMediaEnabled = useBetaFeature(CHAT_MEDIA_BETA);
const clearTimer = useCallback(() => { const clearTimer = useCallback(() => {
if (timerRef.current !== null) { if (timerRef.current !== null) {
@ -411,10 +405,7 @@ export function MessageComposer({
</div> </div>
)} )}
{/* Hidden file inputs driven by the attach menu — only mounted when {/* Hidden file inputs driven by the attach menu. */}
the beta feature is on. */}
{chatMediaEnabled && (
<>
<input <input
ref={imageInputRef} ref={imageInputRef}
type="file" type="file"
@ -445,8 +436,6 @@ export function MessageComposer({
e.target.value = ""; e.target.value = "";
}} }}
/> />
</>
)}
{draft ? ( {draft ? (
<MediaDraftPreview <MediaDraftPreview
@ -483,9 +472,7 @@ export function MessageComposer({
</div> </div>
) : ( ) : (
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
{/* Attach menu — photo / video / document / voice. Beta-gated: {/* Attach menu — photo / video / document / voice. */}
hidden entirely for accounts not opted into 'chat_media'. */}
{chatMediaEnabled && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={inputsDisabled || busy} disabled={inputsDisabled || busy}
@ -523,7 +510,6 @@ export function MessageComposer({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
<GatedButton <GatedButton
variant="ghost" variant="ghost"
@ -578,14 +564,7 @@ export function MessageComposer({
`items-end` buttons below the textarea. Indented to line up `items-end` buttons below the textarea. Indented to line up
under the textarea left edge. */} under the textarea left edge. */}
{!draft && !recording && ( {!draft && !recording && (
<p <p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground">
className={cn(
"mt-1 text-[10px] text-muted-foreground",
// Line up under the textarea: one button (template) when the
// attach menu is hidden, two when the beta feature adds it.
chatMediaEnabled ? "pl-[5.5rem]" : "pl-11",
)}
>
Type &apos;/&apos; for quick replies Type &apos;/&apos; for quick replies
</p> </p>
)} )}

View File

@ -1,20 +0,0 @@
"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 && <AttachButton />}
*/
export function useBetaFeature(feature: BetaFeature): boolean {
const { profileLoading, profile } = useAuth();
if (profileLoading || !profile) return false;
return hasBetaFeature(profile.beta_features, feature);
}

View File

@ -1,19 +0,0 @@
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);
});
});

View File

@ -1,31 +0,0 @@
/**
* 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;
}