# Shared · Image Lightbox (frontend)

`resources/js/Components/ImageLightbox.vue` — the full-screen image viewer every surface
that shows **more than one image at a time** browses through, plus
`resources/js/composables/useMessageGallery.js`, the collector that turns a conversation
thread into a set it can browse.

> **This is a shared *frontend* component, not a `Src\Common` service.** It lives under
> `shared/` for the same reason [`MediaService`](/docs/modules_handbook/shared/media/readMe.md)
> does — two unrelated modules ([Slot Posters](/docs/modules_handbook/manage/events/slot-posters/readMe.md)
> and [Messages](/docs/modules_handbook/manage/messages/readMe.md)) depend on it, so its
> contract cannot be documented inside either one without the other's maintainer never
> finding it. Read the *Reference usage* section before mounting a third one.

## What it does

Click a thumbnail anywhere, get the picture large, and step through the rest of the set
with **← / →**, the on-stage arrows or the filmstrip. It owns **only the viewing
mechanics** — stage, navigation, keyboard, filmstrip, download. Anything surface-specific
goes in the `#panel` slot, which is how the poster modal hangs *Set as final* / *Use as
reference* off the same component a chat thread uses bare.

## How it works

**The host owns the list AND the index** (`v-model` for open/closed, `v-model:index` for
which one). This is the load-bearing decision: it cannot be internal state, because the
poster modal tracks the open image **by uuid** (a background poll inserting a finished
poster must not silently swap what is on screen) and a chat thread appends messages while
the viewer is open. Both need the index to survive a list that changes underneath it. The
component clamps whatever index it is handed rather than trusting it, so a host whose list
just shrank (a deleted poster, a filter applied) cannot render past the end.

**Built on the shared [`Modal.vue`](/resources/js/Components/Modal.vue), deliberately.**
Modal owns a module-scoped stack, so Escape dismisses only the **topmost** layer and the
body scroll-lock holds until the last one closes. This viewer routinely opens on top of
another modal; a hand-rolled overlay would take Escape from the layer underneath and close
both at once. It mounts Modal with `size="full"` and `:padded="false"` — the opt-in prop
Modal gained for exactly this, so the image can bleed to the panel edge.

**The `images` prop** is the set in display order. Each entry is
`{ url, thumbUrl?, title?, subtitle?, filename?, key? }`:

| Field | Why it exists |
|---|---|
| `url` | May be **null** (a poster still generating) — the stage says so rather than rendering a broken image. |
| `key` | The filmstrip's identity when the set can hold the same url twice. Falls back to the position when a host does not supply one. |
| `title` / `subtitle` | What the default panel shows. A host passing `#panel` owns that column entirely and may ignore both. |

**Arrow keys are bound to the document**, not to a focused element — the reader should not
have to find the panel first. They are bound only while open, and **never while a text
field has focus**, where ← must move the caret instead of the picture (a chat thread has a
composer sitting right underneath).

### `useMessageGallery(getMessages)` — the chat-side collector

Collects every viewable image in a thread into one ordered set, so clicking any picture
browses **all** of them. It takes a **getter**, not a value, so the set stays reactive to
the host's own list without caring whether that is a ref, a prop or a computed — a poll or
a newly-sent image extends the filmstrip while the viewer is open.

Two things it settles that a per-host implementation kept getting wrong:

- **Identity is `message.uuid` + `url`, never url alone.** The same url legitimately
  appears in two bubbles (a forwarded image). Matching a click by url would open the
  **first** bubble carrying it, so clicking the second copy showed the wrong caption and
  the wrong position in the strip. `open(attachment, message)` takes the owning message
  for that reason; the url-only fallback remains only for a caller with no message context.
- **Stickers are included.** They render through the same `<img>` in the bubble, so
  excluding them here would leave a sticker click emitting an open that resolves to no
  index and silently does nothing.

## Reference usage

**The canonical consumer is the WhatsApp inbox** ([`Pages/Manage/Messages/Inbox.vue`](/resources/js/Pages/Manage/Messages/Inbox.vue)) —
the plainest mount, using the default panel:

```vue
const { images: galleryImages, index: galleryIndex, isOpen: galleryOpen, open: openGallery }
    = useMessageGallery(() => searchedThread.value);

<MessageAttachment ... gallery @open="openGallery(a, m)" />

<ImageLightbox v-model="galleryOpen" v-model:index="galleryIndex" :images="galleryImages" />
```

Three rules that mount demonstrates, and a third consumer should copy:

1. **Pass the message alongside the attachment** (`openGallery(a, m)`) — see identity, above.
   `MessageBubble` re-emits `open-image` with both arguments for hosts that render bubbles
   rather than attachments directly ([`ConversationThread.vue`](/resources/js/Components/Conversation/ConversationThread.vue)
   just binds `@open-image="openGallery"` and both arrive).
2. **`gallery` on `MessageAttachment` is opt-in.** Without it an image keeps its original
   new-tab link, so a host that does **not** mount a viewer never renders a picture that
   does nothing when clicked. Only set it when an ancestor actually handles the open.
3. **Build the set from what is on screen, not from everything.** The inbox feeds it the
   **searched** thread: while a message search narrows what is visible, arrowing must not
   wander into bubbles the filter just hid.

**For a surface-specific panel**, see
[`SlotPosterLightbox.vue`](/resources/js/Pages/Manage/Events/Series/Partials/SlotPosterLightbox.vue) —
it is *only* a `#panel` (poster meta + Set as final / Use as reference / Retry / Delete),
with the poster modal owning the list and translating the viewer's index back to the uuid
it tracks by.

## Related files

**Frontend**
- [resources/js/Components/ImageLightbox.vue](/resources/js/Components/ImageLightbox.vue) — the viewer: stage, ←/→, filmstrip, download, `#panel` slot.
- [resources/js/composables/useMessageGallery.js](/resources/js/composables/useMessageGallery.js) — thread → ordered image set (+ `open(attachment, message)`).
- [resources/js/Components/Modal.vue](/resources/js/Components/Modal.vue) — the Escape stack + scroll-lock it is built on; gained the opt-in `padded` prop for it.

**Consumers**
- [resources/js/Pages/Manage/Messages/Inbox.vue](/resources/js/Pages/Manage/Messages/Inbox.vue) — WhatsApp inbox (default panel, searched thread).
- [resources/js/Components/Conversation/ConversationThread.vue](/resources/js/Components/Conversation/ConversationThread.vue) — the lead page's read-only WhatsApp / Messenger threads.
- [resources/js/Components/Conversation/MessageBubble.vue](/resources/js/Components/Conversation/MessageBubble.vue) · [MessageAttachment.vue](/resources/js/Components/Conversation/MessageAttachment.vue) — the opt-in `gallery` trigger and its `open-image` emit.
- [resources/js/Pages/Manage/Events/Series/Partials/SlotPosterLightbox.vue](/resources/js/Pages/Manage/Events/Series/Partials/SlotPosterLightbox.vue) + [SlotPosterModal.vue](/resources/js/Pages/Manage/Events/Series/Partials/SlotPosterModal.vue) — the `#panel` example.

**Tests**
- [resources/js/composables/useMessageGallery.test.js](/resources/js/composables/useMessageGallery.test.js) — thread-wide ordering, non-images skipped, the forwarded-duplicate click, and the set tracking a thread that grows while open.

**Related docs**
- [Slot Posters](/docs/modules_handbook/manage/events/slot-posters/readMe.md) — the `#panel` consumer.
- [Messages](/docs/modules_handbook/manage/messages/readMe.md) · [WhatsApp](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) · [Messenger](/docs/modules_handbook/manage/messages/messenger/readMe.md) — the chat consumers.
