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:
parent
f24619e3d6
commit
551021a2c7
@ -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 ? (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user