@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.
Files changed (58) hide show
  1. package/dist/{Card-CsJvUF_b.js → Card-BdTueeyk.js} +2 -2
  2. package/dist/{Card-CsJvUF_b.js.map → Card-BdTueeyk.js.map} +1 -1
  3. package/dist/{Card-DlMSDSdm.js → Card-ChR37pLZ.js} +2 -2
  4. package/dist/{Card-DlMSDSdm.js.map → Card-ChR37pLZ.js.map} +1 -1
  5. package/dist/{Card-CFFNq49v.js → Card-EKxCn56j.js} +3 -3
  6. package/dist/{Card-CFFNq49v.js.map → Card-EKxCn56j.js.map} +1 -1
  7. package/dist/{LockedThumbnail-DpJx169C.js → LockedThumbnail-B16qP3eH.js} +2 -2
  8. package/dist/{LockedThumbnail-DpJx169C.js.map → LockedThumbnail-B16qP3eH.js.map} +1 -1
  9. package/dist/index-Dn7BC9xK.js +4748 -0
  10. package/dist/index-Dn7BC9xK.js.map +1 -0
  11. package/dist/index.d.ts +591 -25
  12. package/dist/index.js +24 -19
  13. package/package.json +1 -1
  14. package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +841 -0
  15. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +7 -92
  16. package/src/components/LinkAttachment/LinkAttachment.test.tsx +69 -0
  17. package/src/components/LinkAttachment/components/Received/Card.tsx +10 -30
  18. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -1
  19. package/src/components/LinkAttachment/index.tsx +24 -50
  20. package/src/components/LinkAttachment/types.ts +12 -5
  21. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +203 -0
  22. package/src/components/MessageAttachment/Audio/index.tsx +189 -0
  23. package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +352 -0
  24. package/src/components/MessageAttachment/File/index.tsx +240 -0
  25. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +288 -0
  26. package/src/components/MessageAttachment/Image/index.tsx +257 -0
  27. package/src/components/MessageAttachment/MessageAttachment.test.tsx +783 -0
  28. package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +292 -0
  29. package/src/components/MessageAttachment/Pdf/index.tsx +228 -0
  30. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +272 -0
  31. package/src/components/MessageAttachment/Video/index.tsx +281 -0
  32. package/src/components/MessageAttachment/_shared/Bubble.tsx +173 -0
  33. package/src/components/MessageAttachment/_shared/CompactDocumentRow.tsx +152 -0
  34. package/src/components/MessageAttachment/_shared/DismissButton.tsx +39 -0
  35. package/src/components/MessageAttachment/_shared/DownloadAction.tsx +175 -0
  36. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +314 -0
  37. package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +139 -0
  38. package/src/components/MessageAttachment/_shared/PdfViewer.tsx +100 -0
  39. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +171 -0
  40. package/src/components/MessageAttachment/_shared/ViewerShell.tsx +159 -0
  41. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +82 -0
  42. package/src/components/MessageAttachment/_shared/fileMeta.ts +95 -0
  43. package/src/components/MessageAttachment/_shared/triggerDownload.ts +54 -0
  44. package/src/components/MessageAttachment/_shared/useViewer.ts +53 -0
  45. package/src/components/MessageAttachment/index.tsx +149 -0
  46. package/src/components/MessageAttachment/stories/StoryTable.tsx +72 -0
  47. package/src/components/MessageAttachment/types.ts +178 -0
  48. package/src/index.ts +32 -0
  49. package/dist/Card-D32U6KfZ.js +0 -85
  50. package/dist/Card-D32U6KfZ.js.map +0 -1
  51. package/dist/Card-DlSSJPip.js +0 -60
  52. package/dist/Card-DlSSJPip.js.map +0 -1
  53. package/dist/Card-zGbhRBwv.js +0 -48
  54. package/dist/Card-zGbhRBwv.js.map +0 -1
  55. package/dist/CardThumbnail-DTBuRQHF.js +0 -239
  56. package/dist/CardThumbnail-DTBuRQHF.js.map +0 -1
  57. package/dist/index-DfcRe-Hj.js +0 -3103
  58. 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