+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- pan/zoom drag
+ target inside an already-dismissible dialog; alt="" is correct here, same reasoning as
+ ImageAttachment's own thumbnail (chat image content has no meaningful alt text to give it). */}
+ e.stopPropagation()}
+ onDoubleClick={(e) => {
+ e.stopPropagation();
+ toggleZoom();
+ }}
+ onMouseDown={onMouseDown}
+ onMouseMove={onMouseMove}
+ onMouseUp={endDrag}
+ onMouseLeave={endDrag}
+ style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
+ className={`max-h-[85vh] max-w-[85vw] select-none rounded object-contain ${zoom > LIGHTBOX_ZOOM_MIN ? 'cursor-grab active:cursor-grabbing' : ''}`}
+ />
+
+ );
+}
+
+/** `locked` (MessageBubble's selectMode): a tap must only toggle the row's own selection, same
+ * reasoning as QuoteBar/the @mention buttons going inert during bulk-select. Renders the same
+ * thumbnail either way -- only the click behavior (and the lightbox it would open) is disabled, so
+ * entering select mode doesn't visually change every image bubble in the list. */
+export function ImageAttachment({ data, locked }: { data: ImageData; locked?: boolean }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
const src = inline || remote.url;
+ const [viewing, setViewing] = useState(false);
if (!src) {
return (
@@ -25,7 +137,16 @@ export function ImageAttachment({ data }: { data: ImageData }) {
);
}
// README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
- return ;
+ const thumb = ;
+ if (locked) return thumb;
+ return (
+ <>
+
+ {viewing ? setViewing(false)} /> : null}
+ >
+ );
}
interface FileData {
@@ -36,7 +157,11 @@ interface FileData {
size?: number;
}
-export function FileAttachment({ data }: { data: FileData }) {
+/** `locked`: see ImageAttachment's own comment -- a tap during selectMode must only toggle the row,
+ * not start a download (which `preventDefault` blocks for an ordinary click; middle-click/"open in
+ * new tab" aren't specially guarded against, matching this codebase's existing level of rigor for
+ * other locked-during-selectMode controls). */
+export function FileAttachment({ data, locked }: { data: FileData; locked?: boolean }) {
const style = fileTypeStyle(data.name, data.mime);
const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref);
const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null;
@@ -47,7 +172,9 @@ export function FileAttachment({ data }: { data: FileData }) {
href={href}
download={data.name}
onClick={(e) => {
- if (remote.error) {
+ if (locked) {
+ e.preventDefault();
+ } else if (remote.error) {
e.preventDefault();
remote.retry();
} else if (!href) {
@@ -93,8 +220,11 @@ function formatDuration(ms: number | undefined): string {
* favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as
* an ``, so the static poster comes from the separate inline `preview` field (a real JPEG,
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `