@linktr.ee/messaging-react 2.3.0-rc-1779427772 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{Card-DKp7ijLV.js → Card-4takoN_-.js} +6 -6
  2. package/dist/{Card-DKp7ijLV.js.map → Card-4takoN_-.js.map} +1 -1
  3. package/dist/{Card-Djm6JjNo.js → Card-BuROm0u7.js} +19 -19
  4. package/dist/{Card-Djm6JjNo.js.map → Card-BuROm0u7.js.map} +1 -1
  5. package/dist/{Card-BlzGsGam.cjs → Card-CexShqpK.cjs} +2 -2
  6. package/dist/{Card-BlzGsGam.cjs.map → Card-CexShqpK.cjs.map} +1 -1
  7. package/dist/{Card-BkWwtS0b.cjs → Card-CgpHBx-W.cjs} +2 -2
  8. package/dist/{Card-BkWwtS0b.cjs.map → Card-CgpHBx-W.cjs.map} +1 -1
  9. package/dist/{Card-B7yHs01-.js → Card-DdpdnSh_.js} +16 -16
  10. package/dist/{Card-B7yHs01-.js.map → Card-DdpdnSh_.js.map} +1 -1
  11. package/dist/{Card-DApWNv5V.cjs → Card-ot16XqS2.cjs} +2 -2
  12. package/dist/{Card-DApWNv5V.cjs.map → Card-ot16XqS2.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-BjF6khtg.cjs → LockedThumbnail-CydtYOSA.cjs} +2 -2
  14. package/dist/{LockedThumbnail-BjF6khtg.cjs.map → LockedThumbnail-CydtYOSA.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-pm6jo2B4.js → LockedThumbnail-Drsh4B5o.js} +8 -8
  16. package/dist/{LockedThumbnail-pm6jo2B4.js.map → LockedThumbnail-Drsh4B5o.js.map} +1 -1
  17. package/dist/assets/index.css +1 -1
  18. package/dist/index-BCbVXFHI.js +4698 -0
  19. package/dist/index-BCbVXFHI.js.map +1 -0
  20. package/dist/index-CQ913euH.cjs +2 -0
  21. package/dist/index-CQ913euH.cjs.map +1 -0
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.d.ts +22 -13
  24. package/dist/index.js +1 -1
  25. package/package.json +1 -1
  26. package/src/components/ChannelView.tsx +2 -8
  27. package/src/components/CustomMessage/CustomMessage.stories.tsx +0 -140
  28. package/src/components/CustomMessage/index.tsx +15 -20
  29. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +8 -5
  30. package/src/components/MessageAttachment/Image/index.tsx +7 -1
  31. package/src/components/MessageAttachment/MessageAttachment.test.tsx +200 -19
  32. package/src/components/MessageAttachment/Pdf/index.tsx +14 -15
  33. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +2 -2
  34. package/src/components/MessageAttachment/Video/index.tsx +11 -2
  35. package/src/components/MessageAttachment/_shared/CarouselNav.tsx +47 -0
  36. package/src/components/MessageAttachment/_shared/DownloadAction.tsx +27 -27
  37. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +59 -261
  38. package/src/components/MessageAttachment/_shared/PdfViewer.tsx +56 -30
  39. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +53 -109
  40. package/src/components/MessageAttachment/_shared/ViewerShell.tsx +127 -107
  41. package/src/components/MessageAttachment/_shared/useCarousel.ts +103 -0
  42. package/src/components/MessageAttachment/index.tsx +18 -9
  43. package/src/components/MessageAttachment/types.ts +1 -1
  44. package/src/styles.css +177 -85
  45. package/dist/index-7sLuX6s4.cjs +0 -18
  46. package/dist/index-7sLuX6s4.cjs.map +0 -1
  47. package/dist/index-Co-LV7yc.js +0 -8220
  48. package/dist/index-Co-LV7yc.js.map +0 -1
  49. package/src/components/CustomMessage/CustomMessageActions.tsx +0 -35
@@ -1,13 +1,10 @@
1
- import {
2
- CaretLeftIcon,
3
- CaretRightIcon,
4
- } from '@phosphor-icons/react'
5
- import React, { useCallback, useEffect, useMemo, useState } from 'react'
1
+ import React, { useMemo } from 'react'
6
2
 
7
3
  import type { MediaPreloadMode } from '../types'
8
4
 
9
- import DownloadAction from './DownloadAction'
5
+ import CarouselNav from './CarouselNav'
10
6
  import { filenameFromUrl } from './fileMeta'
7
+ import { useCarousel } from './useCarousel'
11
8
  import ViewerShell from './ViewerShell'
12
9
 
13
10
  export interface VideoViewerItem {
@@ -28,19 +25,20 @@ export interface VideoViewerItem {
28
25
  export interface VideoViewerProps {
29
26
  open: boolean
30
27
  items: VideoViewerItem[]
28
+ /** Index to display when the viewer first opens. Defaults to `0`. */
31
29
  initialIndex?: number
32
30
  onClose: () => void
33
31
  }
34
32
 
35
- const clamp = (value: number, min: number, max: number) =>
36
- Math.min(Math.max(value, min), max)
37
-
38
33
  /**
39
34
  * Full-viewport video viewer used by every `MessageAttachment.Video.*`
40
35
  * variant. Renders a single `<video controls>` element with the
41
- * browser's native play / pause / seek / fullscreen / volume chrome,
42
- * plus a download action in the toolbar and arrow-key navigation
43
- * between stacked items.
36
+ * browser's native chrome (play / pause / seek / fullscreen / volume /
37
+ * download).
38
+ *
39
+ * When `items.length > 1` the viewer becomes a carousel — see
40
+ * `ImageViewer` for the same behavior; arrow-key navigation yields to
41
+ * the focused `<video>` so the native seek shortcuts still work.
44
42
  */
45
43
  const VideoViewer: React.FC<VideoViewerProps> = ({
46
44
  open,
@@ -48,37 +46,11 @@ const VideoViewer: React.FC<VideoViewerProps> = ({
48
46
  initialIndex = 0,
49
47
  onClose,
50
48
  }) => {
51
- const safeIndex = clamp(initialIndex, 0, Math.max(items.length - 1, 0))
52
- const [index, setIndex] = useState(safeIndex)
53
-
54
- useEffect(() => {
55
- if (!open) return
56
- setIndex(clamp(initialIndex, 0, Math.max(items.length - 1, 0)))
57
- }, [open, initialIndex, items.length])
58
-
59
- const goPrev = useCallback(
60
- () => setIndex((i) => (i <= 0 ? items.length - 1 : i - 1)),
61
- [items.length]
62
- )
63
- const goNext = useCallback(
64
- () => setIndex((i) => (i >= items.length - 1 ? 0 : i + 1)),
65
- [items.length]
66
- )
67
-
68
- useEffect(() => {
69
- if (!open || items.length <= 1) return undefined
70
- const onKey = (e: KeyboardEvent) => {
71
- if (e.key === 'ArrowRight') {
72
- e.preventDefault()
73
- goNext()
74
- } else if (e.key === 'ArrowLeft') {
75
- e.preventDefault()
76
- goPrev()
77
- }
78
- }
79
- window.addEventListener('keydown', onKey)
80
- return () => window.removeEventListener('keydown', onKey)
81
- }, [open, items.length, goPrev, goNext])
49
+ const { index, prev, next } = useCarousel({
50
+ length: items.length,
51
+ initialIndex,
52
+ open,
53
+ })
82
54
 
83
55
  const item = items[index]
84
56
  const filename = useMemo(
@@ -88,81 +60,53 @@ const VideoViewer: React.FC<VideoViewerProps> = ({
88
60
 
89
61
  if (!item) return null
90
62
 
91
- const totalLabel = items.length > 1 ? ` (${index + 1} / ${items.length})` : ''
92
-
93
63
  return (
94
64
  <ViewerShell
95
65
  open={open}
96
66
  onClose={onClose}
97
- title={`${filename}${totalLabel}`}
98
- actions={
99
- <DownloadAction
100
- url={item.src}
101
- filename={filename}
102
- variant="overlay"
103
- label={`Download ${filename}`}
104
- />
67
+ ariaLabel={filename}
68
+ counter={
69
+ items.length > 1 ? `${index + 1} / ${items.length}` : undefined
105
70
  }
106
71
  data-testid="video-viewer"
107
72
  >
108
- <div className="flex h-full w-full items-center justify-center px-6 py-16">
109
- {/* No `<track>` is rendered we don't author caption sidecars
110
- for chat attachments, and an empty `<track kind="captions" />`
111
- emits a runtime warning. Re-add a real track (with `src`,
112
- `srcLang`, and `default`) once caption support ships. The
113
- `jsx-a11y/media-has-caption` rule is suppressed for the
114
- same reason. */}
115
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
116
- <video
117
- // Forcing a key swap on item change ensures the new source
118
- // mounts a fresh element instead of reusing the previous
119
- // playback state — keeps the video paused-at-start when
120
- // navigating between stacked items.
121
- key={`${index}:${item.src}`}
122
- src={item.src}
123
- poster={item.poster}
124
- controls
125
- // `autoPlay` without `muted` is blocked by Chrome / Safari /
126
- // Firefox autoplay policies the click that opens the viewer
127
- // doesn't count as a user-gesture for the new `<video>`
128
- // element after the `key` remount. Defaulting to muted means
129
- // playback actually starts (matches how IG / Slack handle
130
- // video lightboxes); the user can unmute via the native
131
- // controls if they want sound.
132
- autoPlay
133
- muted
134
- playsInline
135
- // The active video defaults to `'metadata'` so duration /
136
- // first-frame appear immediately. Honor an explicit per-item
137
- // override (e.g. `'auto'` for a hero clip the caller wants
138
- // pre-warmed). Non-active siblings aren't mounted at all
139
- // because the `key` above swaps the element on navigation.
140
- preload={item.preload ?? 'metadata'}
141
- className="max-h-full max-w-full bg-black"
142
- >
143
- {item.mimeType ? <source src={item.src} type={item.mimeType} /> : null}
144
- </video>
145
- </div>
73
+ {/* No `<track>` is rendered we don't author caption sidecars
74
+ for chat attachments, and an empty `<track kind="captions" />`
75
+ emits a runtime warning. Re-add a real track (with `src`,
76
+ `srcLang`, and `default`) once caption support ships. */}
77
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
78
+ <video
79
+ // Forcing a key swap on item change ensures the new source
80
+ // mounts a fresh element instead of reusing the previous
81
+ // playback state — keeps the video paused-at-start when
82
+ // navigating between stacked items.
83
+ key={`${index}:${item.src}`}
84
+ src={item.src}
85
+ poster={item.poster}
86
+ controls
87
+ // `autoPlay` without `muted` is blocked by Chrome / Safari /
88
+ // Firefox autoplay policies — the click that opens the viewer
89
+ // doesn't count as a user-gesture for the freshly-mounted
90
+ // `<video>` element. Defaulting to muted means playback
91
+ // actually starts (matches how IG / Slack handle their video
92
+ // lightboxes); the user can unmute via the native controls if
93
+ // they want sound.
94
+ autoPlay
95
+ muted
96
+ playsInline
97
+ preload={item.preload ?? 'metadata'}
98
+ className="mes-media-viewer__video"
99
+ >
100
+ {item.mimeType ? <source src={item.src} type={item.mimeType} /> : null}
101
+ </video>
146
102
 
147
103
  {items.length > 1 ? (
148
- <>
149
- <button
150
- type="button"
151
- onClick={goPrev}
152
- aria-label="Previous video"
153
- className="absolute left-4 top-1/2 z-10 flex size-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
154
- >
155
- <CaretLeftIcon className="size-5" weight="bold" aria-hidden />
156
- </button>
157
- <button
158
- type="button"
159
- onClick={goNext}
160
- aria-label="Next video"
161
- className="absolute right-4 top-1/2 z-10 flex size-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
162
- >
163
- <CaretRightIcon className="size-5" weight="bold" aria-hidden />
164
- </button>
165
- </>
104
+ <CarouselNav
105
+ onPrev={prev}
106
+ onNext={next}
107
+ prevLabel="Previous video"
108
+ nextLabel="Next video"
109
+ />
166
110
  ) : null}
167
111
  </ViewerShell>
168
112
  )
@@ -1,158 +1,178 @@
1
1
  import { XIcon } from '@phosphor-icons/react'
2
- import classNames from 'classnames'
3
2
  import React, { useEffect, useRef } from 'react'
4
- import { createPortal } from 'react-dom'
5
3
 
6
4
  export interface ViewerShellProps {
7
5
  open: boolean
8
6
  onClose: () => void
9
- /** Toolbar title — usually the filename. */
10
- title?: string
11
- /** Buttons rendered to the right of the title in the toolbar. */
7
+ /**
8
+ * Accessible label for the dialog — usually the media's filename. We
9
+ * intentionally don't render a visible title bar (the chrome is a
10
+ * single close button + optional actions so the media stays the
11
+ * focus), so this is the only way assistive tech gets a name for
12
+ * the modal.
13
+ */
14
+ ariaLabel?: string
15
+ /**
16
+ * Optional counter rendered in the top-left chrome (e.g. `2 / 6`).
17
+ * Carousel-aware viewers set this when their items array has more
18
+ * than one entry; omit it for single-item viewers.
19
+ */
20
+ counter?: React.ReactNode
21
+ /**
22
+ * Extra buttons rendered alongside the close button in the top-right
23
+ * chrome (e.g. a download action). They share `.mes-media-viewer__action`
24
+ * styling so the row reads as one consistent button group.
25
+ */
12
26
  actions?: React.ReactNode
13
- /** Extra classes for the content slot wrapper. */
14
- contentClassName?: string
15
27
  children: React.ReactNode
16
28
  'data-testid'?: string
17
29
  }
18
30
 
19
- // Module-scoped counter coordinates the body-scroll lock across
20
- // concurrently mounted viewers. Without it, the inner viewer's
21
- // unmount cleanup would restore `overflow` while the outer viewer is
22
- // still up — the page would scroll behind the still-open dialog.
23
- //
24
- // We snapshot the original `overflow` on the *first* lock and restore
25
- // it on the *last* unlock. Anything in between is a no-op.
26
- let activeLockCount = 0
27
- let previousBodyOverflow: string | null = null
28
-
29
- const lockBodyScroll = () => {
30
- if (typeof document === 'undefined') return
31
- if (activeLockCount === 0) {
32
- previousBodyOverflow = document.body.style.overflow
33
- document.body.style.overflow = 'hidden'
34
- }
35
- activeLockCount += 1
36
- }
37
-
38
- const unlockBodyScroll = () => {
39
- if (typeof document === 'undefined') return
40
- if (activeLockCount === 0) return
41
- activeLockCount -= 1
42
- if (activeLockCount === 0) {
43
- document.body.style.overflow = previousBodyOverflow ?? ''
44
- previousBodyOverflow = null
45
- }
46
- }
47
-
48
31
  /**
49
- * Full-viewport modal portal shared by `ImageViewer`, `VideoViewer`,
50
- * and `PdfViewer`.
32
+ * Full-viewport lightbox shell shared by `ImageViewer`, `VideoViewer`,
33
+ * and `PdfViewer`. Built on a native `<dialog>` so the package ships
34
+ * without a Tailwind dependency for the load-bearing chrome:
51
35
  *
52
- * Responsibilities:
53
- * - Locks body scroll while open (reference-counted so stacked
54
- * viewers don't tear each other's locks down).
55
- * - Closes on `Escape`.
56
- * - Moves focus into the dialog on mount (the close button by
57
- * default) and restores it to the previously-focused element on
58
- * unmount, so opening + closing a viewer via the keyboard returns
59
- * the user to where they were.
60
- * - Renders a thin top toolbar (title + actions + close) layered
61
- * over the content.
36
+ * - Top-layer + body-scroll lock come for free from `showModal()`.
37
+ * - `Escape` to close is wired by the platform.
38
+ * - Focus management (move-in on open, restore on close) is handled
39
+ * by the browser; we explicitly focus the close button as a
40
+ * fallback for environments where `showModal` is unavailable
41
+ * (jsdom under tests).
42
+ *
43
+ * The structural styling lives as plain CSS under `.mes-media-viewer*`
44
+ * in `styles.css` so a consumer who imports
45
+ * `@linktr.ee/messaging-react/styles.css` gets a working lightbox even
46
+ * if their Tailwind pipeline isn't scanning our `dist`.
62
47
  */
63
48
  const ViewerShell: React.FC<ViewerShellProps> = ({
64
49
  open,
65
50
  onClose,
66
- title,
51
+ ariaLabel,
52
+ counter,
67
53
  actions,
68
- contentClassName,
69
54
  children,
70
55
  'data-testid': dataTestId,
71
56
  }) => {
72
- const dialogRef = useRef<HTMLDivElement | null>(null)
57
+ const dialogRef = useRef<HTMLDialogElement | null>(null)
73
58
  const closeButtonRef = useRef<HTMLButtonElement | null>(null)
74
59
 
60
+ // Drive `<dialog>` open/close imperatively from the React `open` prop.
61
+ // We guard `showModal` / `close` because jsdom 23 ships the element
62
+ // without those methods — falling back to the `[open]` attribute
63
+ // keeps tests (and any other non-DOM environment) functional even if
64
+ // the true top-layer modal behavior isn't available there.
75
65
  useEffect(() => {
76
- if (!open) return undefined
66
+ const dialog = dialogRef.current
67
+ if (!dialog) return undefined
77
68
 
78
- lockBodyScroll()
69
+ if (open) {
70
+ if (!dialog.open) {
71
+ if (typeof dialog.showModal === 'function') {
72
+ try {
73
+ dialog.showModal()
74
+ } catch {
75
+ dialog.setAttribute('open', '')
76
+ }
77
+ } else {
78
+ dialog.setAttribute('open', '')
79
+ }
80
+ }
79
81
 
80
- // Remember what was focused before the viewer opened so we can
81
- // hand focus back on close. Cast through `Element` because
82
- // `activeElement` is typed as `Element | null` and we only call
83
- // `.focus()` when it's actually an `HTMLElement`.
84
- const previouslyFocused =
85
- typeof document !== 'undefined'
86
- ? (document.activeElement as HTMLElement | null)
87
- : null
82
+ const previouslyFocused =
83
+ typeof document !== 'undefined'
84
+ ? (document.activeElement as HTMLElement | null)
85
+ : null
88
86
 
89
- // Move focus into the dialog so keyboard users land inside the
90
- // modal. Prefer the close button (most common deliberate action);
91
- // fall back to the dialog itself when the button isn't rendered
92
- // yet for some reason.
93
- const focusTarget = closeButtonRef.current ?? dialogRef.current
94
- focusTarget?.focus()
87
+ // Explicitly focus the close button. In a real browser
88
+ // `showModal()` would already focus the first sequentially
89
+ // focusable child (which is the close button), but doing it
90
+ // ourselves guarantees the same behavior in jsdom and in any
91
+ // browser where focus heuristics differ.
92
+ closeButtonRef.current?.focus()
95
93
 
96
- const handleKey = (e: KeyboardEvent) => {
97
- if (e.key === 'Escape') {
98
- e.preventDefault()
99
- onClose()
94
+ return () => {
95
+ if (dialog.open) {
96
+ if (typeof dialog.close === 'function') {
97
+ try {
98
+ dialog.close()
99
+ } catch {
100
+ dialog.removeAttribute('open')
101
+ }
102
+ } else {
103
+ dialog.removeAttribute('open')
104
+ }
105
+ }
106
+ if (previouslyFocused && document.body.contains(previouslyFocused)) {
107
+ previouslyFocused.focus()
108
+ }
100
109
  }
101
110
  }
102
- window.addEventListener('keydown', handleKey)
103
111
 
104
- return () => {
105
- unlockBodyScroll()
106
- window.removeEventListener('keydown', handleKey)
107
- // Restore focus to whatever was focused before we mounted. If
108
- // that element is gone (e.g. removed from the DOM) we skip
109
- // silently — focusing a stale node would throw.
110
- if (previouslyFocused && document.body.contains(previouslyFocused)) {
111
- previouslyFocused.focus()
112
+ if (dialog.open) {
113
+ if (typeof dialog.close === 'function') {
114
+ try {
115
+ dialog.close()
116
+ } catch {
117
+ dialog.removeAttribute('open')
118
+ }
119
+ } else {
120
+ dialog.removeAttribute('open')
112
121
  }
113
122
  }
114
- }, [open, onClose])
123
+ return undefined
124
+ }, [open])
115
125
 
116
- if (!open || typeof document === 'undefined') return null
126
+ // The platform fires `close` when the user dismisses via Escape or
127
+ // any other native path. Mirror that into React state so the parent's
128
+ // `open` flag stays in sync — otherwise the dialog would close
129
+ // visually but our effect would re-open it on the next render.
130
+ const handleDialogClose = () => {
131
+ onClose()
132
+ }
117
133
 
118
- return createPortal(
119
- <div
134
+ // Backdrop click closes the viewer. The click lands on the `<dialog>`
135
+ // itself (not its children) because the body sits inside an inner
136
+ // wrapper — the easiest reliable check.
137
+ const handleDialogClick = (e: React.MouseEvent<HTMLDialogElement>) => {
138
+ if (e.target === dialogRef.current) onClose()
139
+ }
140
+
141
+ return (
142
+ // The native `<dialog>` element ships its own keyboard dismissal
143
+ // (Escape, handled by the platform and surfaced through `onClose`),
144
+ // so the `onClick` on the backdrop doesn't need a paired keyboard
145
+ // listener — that's the whole point of using `<dialog>`. The
146
+ // `jsx-a11y` rules below don't model `<dialog>` as interactive, so
147
+ // we silence them locally with the rationale captured here.
148
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
149
+ <dialog
120
150
  ref={dialogRef}
121
- role="dialog"
122
- aria-modal="true"
123
- aria-label={title ?? 'Attachment viewer'}
151
+ className="mes-media-viewer"
152
+ aria-label={ariaLabel ?? 'Attachment viewer'}
124
153
  data-testid={dataTestId}
125
- tabIndex={-1}
126
- className="fixed inset-0 z-[1000] flex flex-col bg-black/90 outline-none"
154
+ onClose={handleDialogClose}
155
+ onClick={handleDialogClick}
127
156
  >
128
- <div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center gap-3 bg-gradient-to-b from-black/70 to-transparent px-4 py-3 text-white">
129
- <p className="pointer-events-auto min-w-0 flex-1 truncate text-sm font-medium">
130
- {title}
131
- </p>
132
- <div className="pointer-events-auto flex shrink-0 items-center gap-2">
157
+ <div className="mes-media-viewer__chrome">
158
+ {counter ? (
159
+ <span className="mes-media-viewer__counter">{counter}</span>
160
+ ) : null}
161
+ <div className="mes-media-viewer__actions">
133
162
  {actions}
134
163
  <button
135
164
  ref={closeButtonRef}
136
165
  type="button"
137
166
  onClick={onClose}
138
167
  aria-label="Close viewer"
139
- className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
168
+ className="mes-media-viewer__action"
140
169
  >
141
- <XIcon className="size-5" weight="bold" aria-hidden />
170
+ <XIcon size={20} weight="bold" aria-hidden />
142
171
  </button>
143
172
  </div>
144
173
  </div>
145
-
146
- <div
147
- className={classNames(
148
- 'relative z-0 flex h-full w-full flex-1 items-center justify-center overflow-hidden',
149
- contentClassName
150
- )}
151
- >
152
- {children}
153
- </div>
154
- </div>,
155
- document.body
174
+ <div className="mes-media-viewer__body">{children}</div>
175
+ </dialog>
156
176
  )
157
177
  }
158
178
 
@@ -0,0 +1,103 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+
3
+ const clamp = (value: number, min: number, max: number) =>
4
+ Math.min(Math.max(value, min), max)
5
+
6
+ export interface UseCarouselOptions {
7
+ /** Total number of items in the carousel. */
8
+ length: number
9
+ /** Index to display when the carousel first opens / re-opens. */
10
+ initialIndex: number
11
+ /** When `false`, the keyboard listener is detached. */
12
+ open: boolean
13
+ }
14
+
15
+ export interface UseCarouselResult {
16
+ /** Currently displayed index (clamped to `[0, length - 1]`). */
17
+ index: number
18
+ /** Step one item back, wrapping at the start. */
19
+ prev: () => void
20
+ /** Step one item forward, wrapping at the end. */
21
+ next: () => void
22
+ }
23
+
24
+ /**
25
+ * Tiny carousel state used by `ImageViewer`, `VideoViewer`, and
26
+ * `PdfViewer` when their items array has more than one entry.
27
+ *
28
+ * - Resets to `initialIndex` whenever the viewer (re-)opens, so
29
+ * clicking a different tile on the bubble surface lands on that
30
+ * item rather than restoring the last-viewed position.
31
+ * - Clamps defensively when `length` shrinks below the active index
32
+ * (server push / optimistic update / stack edit) so the viewer
33
+ * gracefully falls back instead of dereferencing `undefined`.
34
+ * - Listens for `ArrowLeft` / `ArrowRight` while the viewer is open,
35
+ * but yields when the focused element is a `<video>` / `<audio>`
36
+ * element so the native media controls can use the same keys for
37
+ * seek / volume.
38
+ */
39
+ export const useCarousel = ({
40
+ length,
41
+ initialIndex,
42
+ open,
43
+ }: UseCarouselOptions): UseCarouselResult => {
44
+ const safeInitial = clamp(initialIndex, 0, Math.max(length - 1, 0))
45
+ const [index, setIndex] = useState(safeInitial)
46
+
47
+ // Snap the active index back to whatever the caller asked for every
48
+ // time the viewer (re)opens or its initial target / length changes.
49
+ // Without this the viewer would remember the last index across
50
+ // open/close cycles, which is wrong for a chat lightbox where each
51
+ // tile click should land on that tile.
52
+ useEffect(() => {
53
+ if (!open) return
54
+ setIndex(clamp(initialIndex, 0, Math.max(length - 1, 0)))
55
+ }, [open, initialIndex, length])
56
+
57
+ // Re-clamp on length change so a stale index doesn't dereference
58
+ // past the new array bounds (e.g. items shrank while the viewer is
59
+ // open). The `Math.max(..., 0)` keeps the index at `0` when the
60
+ // array empties out so callers can render an empty-state instead of
61
+ // a runtime crash.
62
+ useEffect(() => {
63
+ setIndex((i) => clamp(i, 0, Math.max(length - 1, 0)))
64
+ }, [length])
65
+
66
+ const prev = useCallback(() => {
67
+ if (length <= 1) return
68
+ setIndex((i) => (i <= 0 ? length - 1 : i - 1))
69
+ }, [length])
70
+
71
+ const next = useCallback(() => {
72
+ if (length <= 1) return
73
+ setIndex((i) => (i >= length - 1 ? 0 : i + 1))
74
+ }, [length])
75
+
76
+ useEffect(() => {
77
+ if (!open || length <= 1) return undefined
78
+
79
+ const onKey = (e: KeyboardEvent) => {
80
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
81
+ // Don't hijack arrow keys when a native media element is
82
+ // focused — the browser's own controls already use them for
83
+ // seek / volume, and stealing them would feel broken.
84
+ const active = document.activeElement
85
+ if (
86
+ active &&
87
+ (active.tagName === 'VIDEO' || active.tagName === 'AUDIO')
88
+ ) {
89
+ return
90
+ }
91
+ e.preventDefault()
92
+ if (e.key === 'ArrowLeft') prev()
93
+ else next()
94
+ }
95
+
96
+ window.addEventListener('keydown', onKey)
97
+ return () => window.removeEventListener('keydown', onKey)
98
+ }, [open, length, prev, next])
99
+
100
+ return { index, prev, next }
101
+ }
102
+
103
+ export default useCarousel
@@ -49,13 +49,15 @@ import VideoAttachment, {
49
49
  *
50
50
  * - Image / Video render a 1 / 2 / 3 / 4-tile grid (5+ collapse
51
51
  * into a `+N` overflow tile). Activating any tile opens the
52
- * built-in `ImageViewer` / `VideoViewer` at that index, with
53
- * arrow-key navigation between siblings.
52
+ * built-in `ImageViewer` / `VideoViewer` at that index; when
53
+ * `items.length > 1` the lightbox becomes a carousel (prev / next
54
+ * chrome, ArrowLeft / ArrowRight, current / total counter) so the
55
+ * user can browse siblings without closing the modal.
54
56
  * - Pdf / Audio / File render their compact rows / players in a
55
57
  * vertical stack with an 8px gap between rows. Each row keeps
56
- * its own activation target — Pdf opens the viewer at that
57
- * index, Audio gets its own native player, and File downloads
58
- * the asset for that row.
58
+ * its own activation target — Pdf opens the carousel-aware
59
+ * `PdfViewer` at that index, Audio gets its own native player,
60
+ * and File downloads the asset for that row.
59
61
  *
60
62
  * The Composer surface accepts only a single attachment at a time,
61
63
  * so its props omit `items` / `text`.
@@ -66,10 +68,17 @@ import VideoAttachment, {
66
68
  * message. Sent / Received wrap the media in the shared bubble chrome.
67
69
  *
68
70
  * Image / Video / Pdf are interactive in every state (Composer, Sent,
69
- * Received) — clicks open the corresponding native viewer with built-in
70
- * download. File rows download the asset directly. Audio renders a
71
- * native `<audio controls>` player which exposes its own download in
72
- * the kebab menu, so we don't render a separate Download affordance.
71
+ * Received) — clicks open the corresponding native lightbox `<dialog>`,
72
+ * which centers the media at reasonable max dimensions (Escape and
73
+ * backdrop click also dismiss). The Image viewer adds an explicit
74
+ * download button to the chrome; the Video viewer relies on the
75
+ * native `<video controls>` download menu, and the Pdf viewer
76
+ * inherits the browser's built-in PDF download. Sibling-row Download
77
+ * buttons on Pdf / File and the native `<audio>` controls expose
78
+ * downloads outside the viewer too. The lightbox chrome is styled
79
+ * with plain CSS (`.mes-media-viewer*`) so the package's shipped
80
+ * stylesheet is enough to render the viewer correctly without a
81
+ * Tailwind dependency in the consumer.
73
82
  *
74
83
  * # Lazy-loading defaults
75
84
  *
@@ -108,7 +108,7 @@ export interface VideoItem {
108
108
  /** A single PDF inside a Pdf attachment (single or stacked). */
109
109
  export interface PdfItem {
110
110
  src: string
111
- /** Filename — drives the title + the meta line + the viewer toolbar. */
111
+ /** Filename — drives the row title + the meta line + the viewer dialog's accessible name. */
112
112
  filename?: string
113
113
  /** File size in bytes — formatted into the `EXT · SIZE` meta line. */
114
114
  fileSize?: number