@linktr.ee/messaging-react 2.1.0 → 2.2.0-rc-1778753733
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-CsJvUF_b.js → Card-BdTueeyk.js} +2 -2
- package/dist/{Card-CsJvUF_b.js.map → Card-BdTueeyk.js.map} +1 -1
- package/dist/{Card-DlMSDSdm.js → Card-ChR37pLZ.js} +2 -2
- package/dist/{Card-DlMSDSdm.js.map → Card-ChR37pLZ.js.map} +1 -1
- package/dist/{Card-CFFNq49v.js → Card-EKxCn56j.js} +3 -3
- package/dist/{Card-CFFNq49v.js.map → Card-EKxCn56j.js.map} +1 -1
- package/dist/{LockedThumbnail-DpJx169C.js → LockedThumbnail-B16qP3eH.js} +2 -2
- package/dist/{LockedThumbnail-DpJx169C.js.map → LockedThumbnail-B16qP3eH.js.map} +1 -1
- package/dist/index-Dn7BC9xK.js +4748 -0
- package/dist/index-Dn7BC9xK.js.map +1 -0
- package/dist/index.d.ts +591 -25
- package/dist/index.js +24 -19
- package/package.json +1 -1
- package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +841 -0
- package/src/components/LinkAttachment/LinkAttachment.stories.tsx +7 -92
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +69 -0
- package/src/components/LinkAttachment/components/Received/Card.tsx +10 -30
- package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -1
- package/src/components/LinkAttachment/index.tsx +24 -50
- package/src/components/LinkAttachment/types.ts +12 -5
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +203 -0
- package/src/components/MessageAttachment/Audio/index.tsx +189 -0
- package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +352 -0
- package/src/components/MessageAttachment/File/index.tsx +240 -0
- package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +288 -0
- package/src/components/MessageAttachment/Image/index.tsx +257 -0
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +783 -0
- package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +292 -0
- package/src/components/MessageAttachment/Pdf/index.tsx +228 -0
- package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +272 -0
- package/src/components/MessageAttachment/Video/index.tsx +281 -0
- package/src/components/MessageAttachment/_shared/Bubble.tsx +173 -0
- package/src/components/MessageAttachment/_shared/CompactDocumentRow.tsx +152 -0
- package/src/components/MessageAttachment/_shared/DismissButton.tsx +39 -0
- package/src/components/MessageAttachment/_shared/DownloadAction.tsx +175 -0
- package/src/components/MessageAttachment/_shared/ImageViewer.tsx +314 -0
- package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +139 -0
- package/src/components/MessageAttachment/_shared/PdfViewer.tsx +100 -0
- package/src/components/MessageAttachment/_shared/VideoViewer.tsx +171 -0
- package/src/components/MessageAttachment/_shared/ViewerShell.tsx +159 -0
- package/src/components/MessageAttachment/_shared/fileMeta.test.ts +82 -0
- package/src/components/MessageAttachment/_shared/fileMeta.ts +95 -0
- package/src/components/MessageAttachment/_shared/triggerDownload.ts +54 -0
- package/src/components/MessageAttachment/_shared/useViewer.ts +53 -0
- package/src/components/MessageAttachment/index.tsx +149 -0
- package/src/components/MessageAttachment/stories/StoryTable.tsx +72 -0
- package/src/components/MessageAttachment/types.ts +178 -0
- package/src/index.ts +32 -0
- package/dist/Card-D32U6KfZ.js +0 -85
- package/dist/Card-D32U6KfZ.js.map +0 -1
- package/dist/Card-DlSSJPip.js +0 -60
- package/dist/Card-DlSSJPip.js.map +0 -1
- package/dist/Card-zGbhRBwv.js +0 -48
- package/dist/Card-zGbhRBwv.js.map +0 -1
- package/dist/CardThumbnail-DTBuRQHF.js +0 -239
- package/dist/CardThumbnail-DTBuRQHF.js.map +0 -1
- package/dist/index-DfcRe-Hj.js +0 -3103
- package/dist/index-DfcRe-Hj.js.map +0 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CaretLeftIcon,
|
|
3
|
+
CaretRightIcon,
|
|
4
|
+
} from '@phosphor-icons/react'
|
|
5
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
6
|
+
|
|
7
|
+
import type { MediaPreloadMode } from '../types'
|
|
8
|
+
|
|
9
|
+
import DownloadAction from './DownloadAction'
|
|
10
|
+
import { filenameFromUrl } from './fileMeta'
|
|
11
|
+
import ViewerShell from './ViewerShell'
|
|
12
|
+
|
|
13
|
+
export interface VideoViewerItem {
|
|
14
|
+
src: string
|
|
15
|
+
poster?: string
|
|
16
|
+
mimeType?: string
|
|
17
|
+
filename?: string
|
|
18
|
+
/**
|
|
19
|
+
* Per-item `<video preload>` override. Defaults to `'metadata'`
|
|
20
|
+
* for the active item so duration / first-frame are available
|
|
21
|
+
* immediately. Non-active siblings aren't mounted here (we render
|
|
22
|
+
* only `items[index]` at a time via a `key` swap), so they
|
|
23
|
+
* naturally stay unloaded.
|
|
24
|
+
*/
|
|
25
|
+
preload?: MediaPreloadMode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VideoViewerProps {
|
|
29
|
+
open: boolean
|
|
30
|
+
items: VideoViewerItem[]
|
|
31
|
+
initialIndex?: number
|
|
32
|
+
onClose: () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
36
|
+
Math.min(Math.max(value, min), max)
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Full-viewport video viewer used by every `MessageAttachment.Video.*`
|
|
40
|
+
* 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.
|
|
44
|
+
*/
|
|
45
|
+
const VideoViewer: React.FC<VideoViewerProps> = ({
|
|
46
|
+
open,
|
|
47
|
+
items,
|
|
48
|
+
initialIndex = 0,
|
|
49
|
+
onClose,
|
|
50
|
+
}) => {
|
|
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])
|
|
82
|
+
|
|
83
|
+
const item = items[index]
|
|
84
|
+
const filename = useMemo(
|
|
85
|
+
() => item?.filename ?? (item ? filenameFromUrl(item.src) : 'video'),
|
|
86
|
+
[item]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if (!item) return null
|
|
90
|
+
|
|
91
|
+
const totalLabel = items.length > 1 ? ` (${index + 1} / ${items.length})` : ''
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<ViewerShell
|
|
95
|
+
open={open}
|
|
96
|
+
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
|
+
/>
|
|
105
|
+
}
|
|
106
|
+
data-testid="video-viewer"
|
|
107
|
+
>
|
|
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>
|
|
146
|
+
|
|
147
|
+
{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
|
+
</>
|
|
166
|
+
) : null}
|
|
167
|
+
</ViewerShell>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export default VideoViewer
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { XIcon } from '@phosphor-icons/react'
|
|
2
|
+
import classNames from 'classnames'
|
|
3
|
+
import React, { useEffect, useRef } from 'react'
|
|
4
|
+
import { createPortal } from 'react-dom'
|
|
5
|
+
|
|
6
|
+
export interface ViewerShellProps {
|
|
7
|
+
open: boolean
|
|
8
|
+
onClose: () => void
|
|
9
|
+
/** Toolbar title — usually the filename. */
|
|
10
|
+
title?: string
|
|
11
|
+
/** Buttons rendered to the right of the title in the toolbar. */
|
|
12
|
+
actions?: React.ReactNode
|
|
13
|
+
/** Extra classes for the content slot wrapper. */
|
|
14
|
+
contentClassName?: string
|
|
15
|
+
children: React.ReactNode
|
|
16
|
+
'data-testid'?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
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
|
+
/**
|
|
49
|
+
* Full-viewport modal portal shared by `ImageViewer`, `VideoViewer`,
|
|
50
|
+
* and `PdfViewer`.
|
|
51
|
+
*
|
|
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.
|
|
62
|
+
*/
|
|
63
|
+
const ViewerShell: React.FC<ViewerShellProps> = ({
|
|
64
|
+
open,
|
|
65
|
+
onClose,
|
|
66
|
+
title,
|
|
67
|
+
actions,
|
|
68
|
+
contentClassName,
|
|
69
|
+
children,
|
|
70
|
+
'data-testid': dataTestId,
|
|
71
|
+
}) => {
|
|
72
|
+
const dialogRef = useRef<HTMLDivElement | null>(null)
|
|
73
|
+
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!open) return undefined
|
|
77
|
+
|
|
78
|
+
lockBodyScroll()
|
|
79
|
+
|
|
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
|
|
88
|
+
|
|
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()
|
|
95
|
+
|
|
96
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
97
|
+
if (e.key === 'Escape') {
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
onClose()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
window.addEventListener('keydown', handleKey)
|
|
103
|
+
|
|
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
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}, [open, onClose])
|
|
115
|
+
|
|
116
|
+
if (!open || typeof document === 'undefined') return null
|
|
117
|
+
|
|
118
|
+
return createPortal(
|
|
119
|
+
<div
|
|
120
|
+
ref={dialogRef}
|
|
121
|
+
role="dialog"
|
|
122
|
+
aria-modal="true"
|
|
123
|
+
aria-label={title ?? 'Attachment viewer'}
|
|
124
|
+
data-testid={dataTestId}
|
|
125
|
+
tabIndex={-1}
|
|
126
|
+
className="fixed inset-0 z-[1000] flex flex-col bg-black/90 outline-none"
|
|
127
|
+
>
|
|
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">
|
|
133
|
+
{actions}
|
|
134
|
+
<button
|
|
135
|
+
ref={closeButtonRef}
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={onClose}
|
|
138
|
+
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"
|
|
140
|
+
>
|
|
141
|
+
<XIcon className="size-5" weight="bold" aria-hidden />
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
</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
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default ViewerShell
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildCompactMetaLabel,
|
|
5
|
+
filenameFromUrl,
|
|
6
|
+
formatFileSize,
|
|
7
|
+
getFileExtensionLabel,
|
|
8
|
+
} from './fileMeta'
|
|
9
|
+
|
|
10
|
+
describe('formatFileSize', () => {
|
|
11
|
+
it('formats sub-kilobyte sizes as bytes', () => {
|
|
12
|
+
expect(formatFileSize(0)).toBe('0 B')
|
|
13
|
+
expect(formatFileSize(512)).toBe('512 B')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('formats kilobytes / megabytes / gigabytes with sensible precision', () => {
|
|
17
|
+
expect(formatFileSize(1024)).toBe('1.0 KB')
|
|
18
|
+
expect(formatFileSize(388_658)).toBe('379.5 KB')
|
|
19
|
+
expect(formatFileSize(2_457_600)).toBe('2.34 MB')
|
|
20
|
+
expect(formatFileSize(1024 ** 3)).toBe('1.00 GB')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('returns empty string for invalid input', () => {
|
|
24
|
+
expect(formatFileSize(NaN)).toBe('')
|
|
25
|
+
expect(formatFileSize(Infinity)).toBe('')
|
|
26
|
+
expect(formatFileSize(-1)).toBe('')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('getFileExtensionLabel', () => {
|
|
31
|
+
it('prefers the filename extension when present', () => {
|
|
32
|
+
expect(getFileExtensionLabel(undefined, 'notes.pdf')).toBe('PDF')
|
|
33
|
+
expect(getFileExtensionLabel('application/pdf', 'oops.docx')).toBe(
|
|
34
|
+
'DOCX'
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('falls back to MIME-derived label when filename has no extension', () => {
|
|
39
|
+
expect(getFileExtensionLabel('application/pdf')).toBe('PDF')
|
|
40
|
+
expect(getFileExtensionLabel('application/zip')).toBe('ZIP')
|
|
41
|
+
expect(getFileExtensionLabel('application/json')).toBe('JSON')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns undefined for unknown / generic MIME without filename', () => {
|
|
45
|
+
expect(getFileExtensionLabel('application/octet-stream')).toBeUndefined()
|
|
46
|
+
expect(getFileExtensionLabel()).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('buildCompactMetaLabel', () => {
|
|
51
|
+
it('joins extension and size with a middle dot', () => {
|
|
52
|
+
expect(
|
|
53
|
+
buildCompactMetaLabel('application/pdf', 'notes.pdf', 388_658)
|
|
54
|
+
).toBe('PDF · 379.5 KB')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('drops the size when missing', () => {
|
|
58
|
+
expect(buildCompactMetaLabel('application/pdf', 'notes.pdf')).toBe('PDF')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns undefined when nothing is renderable', () => {
|
|
62
|
+
expect(buildCompactMetaLabel()).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('filenameFromUrl', () => {
|
|
67
|
+
it('extracts the last path segment', () => {
|
|
68
|
+
expect(filenameFromUrl('https://cdn.example.com/folder/notes.pdf')).toBe(
|
|
69
|
+
'notes.pdf'
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('decodes URI-encoded segments', () => {
|
|
74
|
+
expect(
|
|
75
|
+
filenameFromUrl('https://cdn.example.com/folder/My%20Notes.pdf')
|
|
76
|
+
).toBe('My Notes.pdf')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('falls back to a default when the URL cannot be parsed', () => {
|
|
80
|
+
expect(filenameFromUrl('not a url at all')).toBe('download')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getDocumentIconType,
|
|
3
|
+
getSourceType,
|
|
4
|
+
} from '../../AttachmentCard/utils/mimeType'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format a byte count as a short human-readable string (`'379.5 KB'`).
|
|
8
|
+
* Mirrors the meta line shown next to file attachments in the mobile chat
|
|
9
|
+
* design — `EXT · SIZE`, e.g. `PDF · 379.5 KB`.
|
|
10
|
+
*/
|
|
11
|
+
export function formatFileSize(bytes: number): string {
|
|
12
|
+
if (!Number.isFinite(bytes) || bytes < 0) return ''
|
|
13
|
+
if (bytes < 1024) return `${bytes} B`
|
|
14
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
15
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
16
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
|
17
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EXTENSION_LABEL_BY_DOCUMENT_TYPE: Record<string, string> = {
|
|
21
|
+
pdf: 'PDF',
|
|
22
|
+
doc: 'DOC',
|
|
23
|
+
xls: 'XLS',
|
|
24
|
+
csv: 'CSV',
|
|
25
|
+
ppt: 'PPT',
|
|
26
|
+
zip: 'ZIP',
|
|
27
|
+
text: 'TXT',
|
|
28
|
+
markdown: 'MD',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns the short uppercase extension label shown in the meta line of
|
|
33
|
+
* compact document / file attachments. Prefers the filename extension
|
|
34
|
+
* when present (matches the mobile design which trusts the filename),
|
|
35
|
+
* and falls back to a label derived from the MIME type.
|
|
36
|
+
*/
|
|
37
|
+
export function getFileExtensionLabel(
|
|
38
|
+
mimeType?: string,
|
|
39
|
+
filename?: string
|
|
40
|
+
): string | undefined {
|
|
41
|
+
if (filename) {
|
|
42
|
+
const lastDot = filename.lastIndexOf('.')
|
|
43
|
+
if (lastDot > 0 && lastDot < filename.length - 1) {
|
|
44
|
+
const ext = filename.slice(lastDot + 1)
|
|
45
|
+
if (ext && ext.length <= 5) return ext.toUpperCase()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!mimeType) return undefined
|
|
50
|
+
|
|
51
|
+
const sourceType = getSourceType(mimeType)
|
|
52
|
+
if (sourceType === 'document') {
|
|
53
|
+
const docType = getDocumentIconType(mimeType)
|
|
54
|
+
const docLabel = EXTENSION_LABEL_BY_DOCUMENT_TYPE[docType]
|
|
55
|
+
if (docLabel) return docLabel
|
|
56
|
+
if (mimeType === 'application/octet-stream') return undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const subtype = mimeType.split('/')[1]
|
|
60
|
+
if (!subtype || subtype === '*') return undefined
|
|
61
|
+
return subtype.toUpperCase()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the meta line shown under the title in compact document / file
|
|
66
|
+
* attachments — `EXT · SIZE` (`PDF · 379.5 KB`). Either part is dropped
|
|
67
|
+
* when not available so audio / generic files still get a useful label.
|
|
68
|
+
*/
|
|
69
|
+
export function buildCompactMetaLabel(
|
|
70
|
+
mimeType?: string,
|
|
71
|
+
filename?: string,
|
|
72
|
+
fileSize?: number
|
|
73
|
+
): string | undefined {
|
|
74
|
+
const ext = getFileExtensionLabel(mimeType, filename)
|
|
75
|
+
const size =
|
|
76
|
+
typeof fileSize === 'number' && fileSize > 0
|
|
77
|
+
? formatFileSize(fileSize)
|
|
78
|
+
: undefined
|
|
79
|
+
return [ext, size].filter(Boolean).join(' · ') || undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Derive a sensible filename from a URL — used as the fallback save name
|
|
84
|
+
* when the attachment has no `filename` set. Returns `'download'` for
|
|
85
|
+
* URLs we can't parse.
|
|
86
|
+
*/
|
|
87
|
+
export function filenameFromUrl(url: string): string {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = new URL(url)
|
|
90
|
+
const last = parsed.pathname.split('/').pop()
|
|
91
|
+
return last && last.length > 0 ? decodeURIComponent(last) : 'download'
|
|
92
|
+
} catch {
|
|
93
|
+
return 'download'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { filenameFromUrl } from './fileMeta'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Download a remote asset with the desired filename.
|
|
5
|
+
*
|
|
6
|
+
* Tries the same-origin `fetch` → `Blob` → invisible-`<a>.download`
|
|
7
|
+
* route first so the file lands on disk under `filename` instead of
|
|
8
|
+
* navigating the tab to it. When CORS / network errors trip us up we
|
|
9
|
+
* fall back to `window.open` and then to a no-window anchor — the
|
|
10
|
+
* popup-blocker fallback ensures the download still fires even when
|
|
11
|
+
* a browser blocks the `_blank` window.
|
|
12
|
+
*
|
|
13
|
+
* Shared by `DownloadAction` (rendered as an explicit Download button
|
|
14
|
+
* in PDF / image / video viewers) and `MessageAttachment.File` rows
|
|
15
|
+
* (where the entire row is the download trigger). Centralizing here
|
|
16
|
+
* keeps one source of truth for the fallback chain.
|
|
17
|
+
*/
|
|
18
|
+
export async function triggerDownload(
|
|
19
|
+
url: string,
|
|
20
|
+
filename?: string
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const name = filename ?? filenameFromUrl(url)
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(url, { mode: 'cors' })
|
|
26
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
27
|
+
const blob = await res.blob()
|
|
28
|
+
const objectUrl = URL.createObjectURL(blob)
|
|
29
|
+
const a = document.createElement('a')
|
|
30
|
+
a.href = objectUrl
|
|
31
|
+
a.download = name
|
|
32
|
+
a.style.display = 'none'
|
|
33
|
+
document.body.appendChild(a)
|
|
34
|
+
a.click()
|
|
35
|
+
document.body.removeChild(a)
|
|
36
|
+
URL.revokeObjectURL(objectUrl)
|
|
37
|
+
} catch {
|
|
38
|
+
const fallback = window.open(url, '_blank', 'noopener,noreferrer')
|
|
39
|
+
if (!fallback) {
|
|
40
|
+
// popup blocked — fall back to a non-windowed anchor
|
|
41
|
+
const a = document.createElement('a')
|
|
42
|
+
a.href = url
|
|
43
|
+
a.download = name
|
|
44
|
+
a.target = '_blank'
|
|
45
|
+
a.rel = 'noopener noreferrer'
|
|
46
|
+
a.style.display = 'none'
|
|
47
|
+
document.body.appendChild(a)
|
|
48
|
+
a.click()
|
|
49
|
+
document.body.removeChild(a)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default triggerDownload
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optional click handler used by every viewer-backed attachment.
|
|
5
|
+
*
|
|
6
|
+
* Return `false` to cancel the default open behavior — useful for
|
|
7
|
+
* route-based viewers or confirmation modals. Any other return value
|
|
8
|
+
* (including `void`/`undefined`) lets the built-in viewer open as
|
|
9
|
+
* normal. The `index` argument identifies the row in stacked
|
|
10
|
+
* attachments and is `0` for the single-attachment shape.
|
|
11
|
+
*/
|
|
12
|
+
export type ViewerClickHandler = (index: number) => boolean | void
|
|
13
|
+
|
|
14
|
+
export interface UseViewerResult {
|
|
15
|
+
viewerOpen: boolean
|
|
16
|
+
viewerIndex: number
|
|
17
|
+
/**
|
|
18
|
+
* Open the viewer at the given index. Forwards to the caller's
|
|
19
|
+
* `onClick` first so analytics + intercept logic can fire before
|
|
20
|
+
* the viewer mounts; if `onClick` returns `false` the open is
|
|
21
|
+
* skipped entirely.
|
|
22
|
+
*/
|
|
23
|
+
handleActivate: (index: number) => void
|
|
24
|
+
closeViewer: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tiny piece of state shared by every `MessageAttachment` that opens
|
|
29
|
+
* a fullscreen viewer (`Image`, `Video`, `Pdf`). Tracks the open flag
|
|
30
|
+
* + the active index, and threads the caller's `onClick` callback
|
|
31
|
+
* through `handleActivate` so consumers can intercept the open
|
|
32
|
+
* (e.g. analytics) or cancel it (return `false` to swap in a
|
|
33
|
+
* route-based viewer / confirmation modal).
|
|
34
|
+
*/
|
|
35
|
+
export const useViewer = (onClick?: ViewerClickHandler): UseViewerResult => {
|
|
36
|
+
const [viewerOpen, setViewerOpen] = useState(false)
|
|
37
|
+
const [viewerIndex, setViewerIndex] = useState(0)
|
|
38
|
+
|
|
39
|
+
const handleActivate = useCallback(
|
|
40
|
+
(index: number) => {
|
|
41
|
+
if (onClick?.(index) === false) return
|
|
42
|
+
setViewerIndex(index)
|
|
43
|
+
setViewerOpen(true)
|
|
44
|
+
},
|
|
45
|
+
[onClick]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const closeViewer = useCallback(() => setViewerOpen(false), [])
|
|
49
|
+
|
|
50
|
+
return { viewerOpen, viewerIndex, handleActivate, closeViewer }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default useViewer
|