@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.
- package/dist/{Card-DKp7ijLV.js → Card-4takoN_-.js} +6 -6
- package/dist/{Card-DKp7ijLV.js.map → Card-4takoN_-.js.map} +1 -1
- package/dist/{Card-Djm6JjNo.js → Card-BuROm0u7.js} +19 -19
- package/dist/{Card-Djm6JjNo.js.map → Card-BuROm0u7.js.map} +1 -1
- package/dist/{Card-BlzGsGam.cjs → Card-CexShqpK.cjs} +2 -2
- package/dist/{Card-BlzGsGam.cjs.map → Card-CexShqpK.cjs.map} +1 -1
- package/dist/{Card-BkWwtS0b.cjs → Card-CgpHBx-W.cjs} +2 -2
- package/dist/{Card-BkWwtS0b.cjs.map → Card-CgpHBx-W.cjs.map} +1 -1
- package/dist/{Card-B7yHs01-.js → Card-DdpdnSh_.js} +16 -16
- package/dist/{Card-B7yHs01-.js.map → Card-DdpdnSh_.js.map} +1 -1
- package/dist/{Card-DApWNv5V.cjs → Card-ot16XqS2.cjs} +2 -2
- package/dist/{Card-DApWNv5V.cjs.map → Card-ot16XqS2.cjs.map} +1 -1
- package/dist/{LockedThumbnail-BjF6khtg.cjs → LockedThumbnail-CydtYOSA.cjs} +2 -2
- package/dist/{LockedThumbnail-BjF6khtg.cjs.map → LockedThumbnail-CydtYOSA.cjs.map} +1 -1
- package/dist/{LockedThumbnail-pm6jo2B4.js → LockedThumbnail-Drsh4B5o.js} +8 -8
- package/dist/{LockedThumbnail-pm6jo2B4.js.map → LockedThumbnail-Drsh4B5o.js.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/index-BCbVXFHI.js +4698 -0
- package/dist/index-BCbVXFHI.js.map +1 -0
- package/dist/index-CQ913euH.cjs +2 -0
- package/dist/index-CQ913euH.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +22 -13
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/ChannelView.tsx +2 -8
- package/src/components/CustomMessage/CustomMessage.stories.tsx +0 -140
- package/src/components/CustomMessage/index.tsx +15 -20
- package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +8 -5
- package/src/components/MessageAttachment/Image/index.tsx +7 -1
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +200 -19
- package/src/components/MessageAttachment/Pdf/index.tsx +14 -15
- package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +2 -2
- package/src/components/MessageAttachment/Video/index.tsx +11 -2
- package/src/components/MessageAttachment/_shared/CarouselNav.tsx +47 -0
- package/src/components/MessageAttachment/_shared/DownloadAction.tsx +27 -27
- package/src/components/MessageAttachment/_shared/ImageViewer.tsx +59 -261
- package/src/components/MessageAttachment/_shared/PdfViewer.tsx +56 -30
- package/src/components/MessageAttachment/_shared/VideoViewer.tsx +53 -109
- package/src/components/MessageAttachment/_shared/ViewerShell.tsx +127 -107
- package/src/components/MessageAttachment/_shared/useCarousel.ts +103 -0
- package/src/components/MessageAttachment/index.tsx +18 -9
- package/src/components/MessageAttachment/types.ts +1 -1
- package/src/styles.css +177 -85
- package/dist/index-7sLuX6s4.cjs +0 -18
- package/dist/index-7sLuX6s4.cjs.map +0 -1
- package/dist/index-Co-LV7yc.js +0 -8220
- package/dist/index-Co-LV7yc.js.map +0 -1
- 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
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
*
|
|
53
|
-
* -
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
51
|
+
ariaLabel,
|
|
52
|
+
counter,
|
|
67
53
|
actions,
|
|
68
|
-
contentClassName,
|
|
69
54
|
children,
|
|
70
55
|
'data-testid': dataTestId,
|
|
71
56
|
}) => {
|
|
72
|
-
const dialogRef = useRef<
|
|
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
|
-
|
|
66
|
+
const dialog = dialogRef.current
|
|
67
|
+
if (!dialog) return undefined
|
|
77
68
|
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
123
|
+
return undefined
|
|
124
|
+
}, [open])
|
|
115
125
|
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
aria-
|
|
123
|
-
aria-label={title ?? 'Attachment viewer'}
|
|
151
|
+
className="mes-media-viewer"
|
|
152
|
+
aria-label={ariaLabel ?? 'Attachment viewer'}
|
|
124
153
|
data-testid={dataTestId}
|
|
125
|
-
|
|
126
|
-
|
|
154
|
+
onClose={handleDialogClose}
|
|
155
|
+
onClick={handleDialogClick}
|
|
127
156
|
>
|
|
128
|
-
<div className="
|
|
129
|
-
|
|
130
|
-
{
|
|
131
|
-
|
|
132
|
-
<div className="
|
|
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="
|
|
168
|
+
className="mes-media-viewer__action"
|
|
140
169
|
>
|
|
141
|
-
<XIcon
|
|
170
|
+
<XIcon size={20} weight="bold" aria-hidden />
|
|
142
171
|
</button>
|
|
143
172
|
</div>
|
|
144
173
|
</div>
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
53
|
-
*
|
|
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
|
|
57
|
-
* index, Audio gets its own native player,
|
|
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
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
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
|