feat(uploads): reject HEIC/HEIF/AVIF; sidebar brand → dashboard link; activity tabs scroll

Three small follow-ups:

1. HEIC/HEIF/AVIF uploads now rejected at the door
   ----------------------------------------------------
   Symptom: an iPhone-shot image uploaded fine but came through on
   WhatsApp without a thumbnail. Bot logs:

       failed to obtain extra info
       heif: Error while loading plugin: Support for this compression
       format has not been built in

   Cause: the bot container's Sharp ships without a HEIF/AVIF
   decoder, so the thumbnail-extraction step Baileys runs throws and
   the message is sent without a preview.

   Fix: the upload validator (`validateForWhatsApp`) now rejects the
   HEIF family before the file ever reaches the action body. Error
   message: "Images are not supported, please re-upload images".

   New tests in `lib/whatsapp-media.test.ts`:
   - `isUnsupportedImageMime` recognises image/heic, image/heif,
     image/heic-sequence, image/avif (case-insensitive).
   - `isUnsupportedImageMime` does NOT flag jpeg/png/webp/gif.
   - `validateForWhatsApp` rejects a HEIC upload regardless of size,
     even below the 5 MB image cap.

2. Desktop sidebar brand is now a link to /
   ----------------------------------------------------
   The mobile header brand pill was already a link to /; the desktop
   sidebar version was a static <div>, so clicking the "cm WhatsApp
   Bot" header in the sidebar did nothing. Wrapped in <Link href="/">
   with `aria-label="Go to dashboard"` and a hover background to
   make the affordance obvious.

3. Activity tab strip switched from full-width to scrollable
   ----------------------------------------------------
   The activity page has six tabs (All / Success / Partial / Failed
   / Skipped / Archived) — packing them into a `w-full` row at h-8
   left every label squeezed to ~50px on mobile. Wrapped the
   <TabsList> in an `overflow-x-auto` scroller (with negative
   horizontal margins so the strip extends to the page edges and the
   first/last tabs aren't clipped) so each tab keeps a comfortable
   touch target on phones; on desktop the row fits naturally and no
   scroll bar appears.

   Reminders page kept its full-width layout — only 4 tabs there,
   they don't crowd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 12:54:30 +08:00
parent f24619e3d6
commit 551021a2c7
4 changed files with 89 additions and 13 deletions

View File

@ -215,17 +215,26 @@ export default async function ActivityPage({ searchParams }: PageProps) {
)}
</div>
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
packed into a phone-width row left every label squeezed to
~50px. Wrap the list in an overflow-x scroller so each tab
keeps a readable label + comfortable touch target on mobile;
on desktop the row fits naturally and no scroll bar appears.
Negative margins extend the scroller to the page edges so the
first/last tabs don't look clipped against the container. */}
<Tabs value={filter}>
<TabsList className="w-full">
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
{label}
</Link>
</TabsTrigger>
))}
</TabsList>
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList>
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
{label}
</Link>
</TabsTrigger>
))}
</TabsList>
</div>
</Tabs>
{filtered.length > 0 ? (

View File

@ -131,8 +131,12 @@ function Sidebar() {
return (
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
{/* Bot name / brand */}
<div className="flex h-14 items-center gap-2 px-4 border-b border-sidebar-border shrink-0">
{/* Bot name / brand — clickable, returns to the dashboard. */}
<Link
href="/"
aria-label="Go to dashboard"
className="flex h-14 items-center gap-2 px-4 border-b border-sidebar-border shrink-0 hover:bg-sidebar-accent/40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
@ -142,7 +146,7 @@ function Sidebar() {
<span className="text-sm font-semibold tracking-tight text-sidebar-foreground">
WhatsApp Bot
</span>
</div>
</Link>
{/* Nav items */}
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">

View File

@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
classifyMediaKind,
formatBytes,
isUnsupportedImageMime,
validateForWhatsApp,
WA_LIMITS,
WA_MAX_BYTES,
@ -99,6 +100,37 @@ describe("validateForWhatsApp — per-kind WA caps", () => {
});
});
describe("isUnsupportedImageMime / HEIC/HEIF/AVIF guard", () => {
it("recognises HEIC, HEIF, AVIF (case-insensitive)", () => {
expect(isUnsupportedImageMime("image/heic")).toBe(true);
expect(isUnsupportedImageMime("image/heif")).toBe(true);
expect(isUnsupportedImageMime("IMAGE/HEIC")).toBe(true);
expect(isUnsupportedImageMime("image/heic-sequence")).toBe(true);
expect(isUnsupportedImageMime("image/avif")).toBe(true);
});
it("does not flag normal image formats", () => {
expect(isUnsupportedImageMime("image/jpeg")).toBe(false);
expect(isUnsupportedImageMime("image/png")).toBe(false);
expect(isUnsupportedImageMime("image/webp")).toBe(false);
expect(isUnsupportedImageMime("image/gif")).toBe(false);
});
it("validateForWhatsApp rejects a HEIC upload with a clear hint", () => {
const r = validateForWhatsApp("image/heic", 2 * MB);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.kind).toBe("image");
expect(r.error).toBe("Images are not supported, please re-upload images");
}
});
it("validateForWhatsApp rejects HEIC even when below the 5 MB image cap", () => {
const r = validateForWhatsApp("image/heif", 100 * 1024); // 100 KB
expect(r.ok).toBe(false);
});
});
describe("WA_MAX_BYTES is the largest single-kind cap", () => {
it("equals the document cap (100 MB)", () => {
expect(WA_MAX_BYTES).toBe(WA_LIMITS.document);

View File

@ -47,6 +47,26 @@ export function classifyMediaKind(mimeType: string): WaMediaKind {
return "document";
}
/**
* MIME types that look like images to the classifier but break the
* Baileys send path because the bot's bundled Sharp doesn't have the
* relevant decoder (HEIF/HEIC, AVIF). The image otherwise uploads
* silently with no thumbnail and a logged warning, which reads to
* users as "image send broken". Rejecting at the upload boundary
* with a useful message is friendlier than the half-success.
*/
const UNSUPPORTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
"image/heic",
"image/heif",
"image/heic-sequence",
"image/heif-sequence",
"image/avif",
]);
export function isUnsupportedImageMime(mimeType: string): boolean {
return UNSUPPORTED_IMAGE_MIMES.has(mimeType.toLowerCase());
}
export type WaSizeCheck =
| { ok: true; kind: WaMediaKind; limitBytes: number }
| { ok: false; kind: WaMediaKind; limitBytes: number; error: string };
@ -67,6 +87,17 @@ export function validateForWhatsApp(
if (sizeBytes <= 0) {
return { ok: false, kind, limitBytes, error: "Empty file" };
}
if (kind === "image" && isUnsupportedImageMime(mimeType)) {
// The bot's Sharp binary doesn't ship a HEIF/AVIF decoder, so the
// thumbnail-extraction step throws and Baileys sends the message
// without a preview. Block at the door with a clear message.
return {
ok: false,
kind,
limitBytes,
error: "Images are not supported, please re-upload images",
};
}
if (sizeBytes > limitBytes) {
return {
ok: false,