WANK 4.0 — Purge Everywhere, View-Once Detection & UX Refinements
Built on top of WANK 3 (documented in
WANK-3.0.md). This covers the photo purge-everywhere system for removing identical photos across all contacts, view-once message detection with CRM alerts, media browser navigation improvements, and UI polish.
Table of Contents
- What Changed Since WANK 3
- Purge Identical Photos Everywhere
- View-Once Message Detection
- Media Browser — Chat Navigation
- Avatar Lightbox Sizing
- Photo Grid Button Visibility
- Files Modified
- Key Lessons & Gotchas
What Changed Since WANK 3
| Category | What Was Added |
|---|---|
| Purge Everywhere | Select any photo in the media browser, find all identical copies by MD5 hash across every contact folder, and replace them all with grey “Removed” placeholders |
| View-Once Detection | Incoming view-once photos/videos are detected and shown as an orange alert in the conversation instead of attempting a media download |
| Media → Chat Nav | Clicking a contact in the media browser sidebar returns to their conversation instead of loading their media |
| Avatar Lightbox | Enlarged minimum size so small WhatsApp avatars don’t appear tiny in the lightbox |
| Button Visibility | Photo grid hover buttons (purge/download/delete) made bright and high-contrast |
Purge Identical Photos Everywhere
Problem
The user sends photos of themselves to many contacts. These identical files accumulate across all contact folders, wasting disk space and leaving personal photos scattered everywhere.
Solution
A two-step flow: scan then replace.
Backend — MediaService
Four new methods:
| Method | Purpose |
|---|---|
getPlaceholder(size) |
Generates a grey 200x200 PNG with “Removed” text via SVG overlay through sharp. Result cached in memory. |
getPlaceholderJpeg(size, quality) |
Same but JPEG output, used for thumbs/ and micro/ directories. Also cached. |
findIdenticalFiles(folder, filename) |
Reads source file, computes MD5. Scans ALL contact folders (photos/ subdirs + flat-layout roots). Uses stat.size pre-check to skip non-matching files without reading them. Returns { hash, size, matches[] }. Guards against re-scanning already-purged files by checking the placeholder’s own MD5. |
purgeIdenticalEverywhere(folder, filename) |
Calls findIdenticalFiles(), then overwrites each match’s photo with the PNG placeholder, its thumbnail with a JPEG placeholder, and its micro-thumbnail with a smaller JPEG placeholder. Returns { replaced, affectedFolders[] }. |
Size pre-check optimization: Before computing an MD5 hash (which requires reading the entire file), findIdenticalFiles compares stat.size against the source. Since most files differ in size, this skips the vast majority without any I/O beyond a stat call.
Placeholder caching: The PNG, thumb JPEG, and micro JPEG placeholders are generated once and held in memory for the lifetime of the process. The placeholder’s own MD5 is also cached to prevent re-purging files that are already placeholders.
Backend — Controller & Routes
| Route | Method | Handler | Purpose |
|---|---|---|---|
/api/media/purge-preview/:folder/:filename |
GET | purgePreview |
Scan for matches, return count + list for confirmation |
/api/media/purge-everywhere/:folder/:filename |
POST | purgeEverywhere |
Execute replacement, return affected count |
Frontend — API
Two new mediaAPI methods:
purgePreview(folder, filename) → { hash, size, matchCount, folderCount, matches[] }
purgeEverywhere(folder, filename) → { replaced, affectedFolders[] }
Frontend — MediaBrowserPage UI
Three UI additions:
-
Photo grid hover button — Orange
Banicon, positioned left of download/delete buttons. Appears on hover over any photo thumbnail. -
Lightbox top bar button — Same orange
Banicon in the full-screen photo viewer, next to download and delete. -
Confirmation modal (z-60, above lightbox z-50):
- Loading spinner while scanning all folders
- Match count, affected folder count, file size, truncated MD5
- Scrollable list of every match (folder + filename)
- Warning that replacement is irreversible
- Cancel / “Replace N files” buttons
- After confirm: replaces files, closes lightbox, refreshes folder view
View-Once Message Detection
Problem
WhatsApp “view once” photos and videos can only be opened on the phone. The CRM has no way to know one was received — the user might miss it entirely.
Solution
Detect msg._data.isViewOnce on incoming messages and surface a visible alert in the conversation.
Backend — WhatsAppService
In handleMessage():
- Checks
msg._data.isViewOncebefore any media download attempt - If true: logs
[View-once photo — check your phone]to the contact’s transcript file - Sets
hasMedia: falsein the serialized message (no broken media placeholder) - Sets
isViewOnce: truein the serialized message - Sets the body to
[View-once photo — check your phone]if body is empty - Returns early — skips
downloadMedia()entirely (it would fail or be empty anyway) - Still emits the WebSocket event so the frontend updates in real-time
In serializeMessage():
- Reads
msg._data.isViewOnceand includes it in the serialized object
In archiveMessage():
- Stores
is_view_onceboolean in the messages table
Database
New column on the messages table:
ALTER TABLE messages ADD COLUMN IF NOT EXISTS is_view_once BOOLEAN DEFAULT FALSE;
Applied to local DB and all production slice databases.
Backend — messageController
The message query row mapper now includes:
isViewOnce: r.is_view_once || false
Frontend — Types
export interface Message {
// ...existing fields...
isViewOnce?: boolean;
}
Frontend — MessageBubble
When message.isViewOnce is true, an orange banner renders above the message body:
EyeOfficon from lucide-react- Text: “View-once — check your phone”
- Styled with
bg-orange-500/10background andborder-orange-500/20border - Appears in the same position as the “Imported” and “AI” badges
Media Browser — Chat Navigation
Before
Clicking a contact in the media browser’s left sidebar loaded their media files in the right panel. To return to the conversation, the user had to manually navigate back to the chat page and find the contact again.
After
Clicking a contact in the sidebar now:
- Calls
selectChat(chat)from the WhatsApp context (loads messages) - Navigates to
/(the chat page)
The user lands directly in the conversation with that contact. To browse media, they can use the “Browse media” button on any photo in the conversation, or navigate to /media via the nav bar.
Implementation: Added useNavigate from react-router-dom and selectChat from the WhatsApp context to MediaBrowserPage. The handleSelectChat handler was simplified from a multi-state update to a two-line function.
Avatar Lightbox Sizing
Problem
WhatsApp profile pictures are typically low-resolution (96x96 to 200x200 pixels). When clicked in the CRM panel, the lightbox rendered the image at its natural size — appearing tiny in the center of a dark overlay.
Fix
Added min-w-[300px] min-h-[300px] to the lightbox <img> element in CrmPanel.tsx. The image now displays at least 300x300 pixels. Higher-resolution images still scale naturally up to the max-w-full max-h-[80vh] constraints.
Photo Grid Button Visibility
Problem
The three hover action buttons on photo thumbnails (purge/download/delete) used muted colors (text-orange-400/70, text-white/70, text-red-400/70) with semi-transparent backgrounds (bg-black/50). On busy photo thumbnails, they were hard to see.
Fix
All three buttons updated to:
- Full-opacity icon colors:
text-orange-400,text-blue-400,text-red-400 - Darker background:
bg-black/80 - Larger icons:
w-4 h-4(up fromw-3.5 h-3.5) - Larger hit target:
p-1.5(up fromp-1) - Drop shadow:
drop-shadow-lg - Hover state: Background fills with the icon’s color, text goes white (e.g.,
hover:bg-orange-500 hover:text-white)
| Button | Default Color | Hover Color |
|---|---|---|
| Remove everywhere | Orange icon on dark bg | Solid orange bg, white icon |
| Download | Blue icon on dark bg | Solid blue bg, white icon |
| Delete | Red icon on dark bg | Solid red bg, white icon |
Files Modified
| File | Changes |
|---|---|
backend/src/services/MediaService.ts |
getPlaceholder(), getPlaceholderJpeg(), findIdenticalFiles(), purgeIdenticalEverywhere() + placeholder cache fields |
backend/src/services/WhatsAppService.ts |
View-once detection in handleMessage(), isViewOnce in serializeMessage(), is_view_once in archiveMessage() |
backend/src/controllers/mediaController.ts |
purgePreview, purgeEverywhere controllers |
backend/src/controllers/messageController.ts |
isViewOnce field in message row mapper |
backend/src/routes/mediaRoutes.ts |
Two new routes: GET purge-preview, POST purge-everywhere |
frontend/src/services/api.ts |
mediaAPI.purgePreview(), mediaAPI.purgeEverywhere() |
frontend/src/types/index.ts |
isViewOnce on Message interface |
frontend/src/pages/MediaBrowserPage.tsx |
Purge button (grid + lightbox), confirmation modal, chat navigation on sidebar click, brighter hover buttons |
frontend/src/components/MessageBubble.tsx |
EyeOff import, view-once orange banner |
frontend/src/components/CrmPanel.tsx |
Avatar lightbox min-w-[300px] min-h-[300px] |
Key Lessons & Gotchas
1. View-Once Property Is on _data, Not the Message Object
whatsapp-web.js does not expose isViewOnce as a top-level Message property. It exists on msg._data.isViewOnce. The library uses it when sending view-once messages (as a sendMessage option), but for received messages it’s only on the raw data object.
2. View-Once Media Download Is Pointless
View-once photos can only be opened on the phone. Even if downloadMedia() succeeds on WhatsApp Web, the content may be empty or a placeholder. The correct approach is to skip the download entirely and alert the user to check their phone.
3. Placeholder Hash Guard Prevents Re-Purging
Without checking whether a file is already a placeholder, running purge-everywhere on a previously purged photo would find all the placeholders (which are now identical to each other) and “purge” them again — an expensive no-op. Caching the placeholder’s MD5 and checking it first makes the operation idempotent.
4. stat.size Pre-Check Is Essential for Performance
Computing MD5 requires reading the entire file. For a media folder with thousands of photos, hashing every file would be extremely slow. Comparing stat.size first (a single syscall) eliminates >99% of candidates immediately since most photos differ in byte size.
5. Context selectChat vs URL Navigation
The chat page doesn’t read a query parameter to select a chat — the active chat is held in React context (WhatsAppContext.selectChat). Navigating to /?chat=xxx doesn’t work. The correct pattern is to call selectChat(chat) first (which loads messages and sets context state), then navigate('/').