@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,173 @@
|
|
|
1
|
+
import classNames from 'classnames'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import type { BubbleGroupPosition, BubbleVariant } from '../types'
|
|
5
|
+
|
|
6
|
+
export interface BubbleProps {
|
|
7
|
+
variant: BubbleVariant
|
|
8
|
+
/** Optional message text rendered below the attachment slot. */
|
|
9
|
+
text?: React.ReactNode
|
|
10
|
+
/**
|
|
11
|
+
* Renders a hairline border around the bubble. Defaults to `true` —
|
|
12
|
+
* matches the design system's "small border around link / message
|
|
13
|
+
* attachments" treatment from the mobile spec.
|
|
14
|
+
*/
|
|
15
|
+
bordered?: boolean
|
|
16
|
+
/**
|
|
17
|
+
* Position of this bubble inside a same-author message run. Drives
|
|
18
|
+
* the corner-flattening that visually merges consecutive bubbles
|
|
19
|
+
* (matches the grouping stream-chat-react applies to text bubbles).
|
|
20
|
+
* Defaults to `'single'` so standalone usage keeps every corner
|
|
21
|
+
* fully rounded.
|
|
22
|
+
*/
|
|
23
|
+
groupPosition?: BubbleGroupPosition
|
|
24
|
+
className?: string
|
|
25
|
+
children: React.ReactNode
|
|
26
|
+
'data-testid'?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Colors and metrics tracked from `stream-chat-react`'s default
|
|
30
|
+
// `.str-chat__message-bubble` token set so attachments visually drop
|
|
31
|
+
// into a normal `CustomMessage` thread without any seams:
|
|
32
|
+
// - light → `--str-chat__secondary-surface-color` = grey200 (#e9eaed)
|
|
33
|
+
// - dark → `.str-chat__message--me` override in `styles.css` (#121110)
|
|
34
|
+
// - text padding → `--str-chat__spacing-2 --str-chat__spacing-4` = 8px 16px
|
|
35
|
+
// - border-radius → `--str-chat__border-radius-md` = 18px
|
|
36
|
+
//
|
|
37
|
+
// TODO: migrate to Tailwind theme tokens once they exist for the
|
|
38
|
+
// `linktree.dark` / `linktree.surface.secondary` palette — these hex
|
|
39
|
+
// literals are repeated across LinkAttachment / LockedAttachment /
|
|
40
|
+
// MediaMessage / CustomMessageInput / DownloadAction etc., so a
|
|
41
|
+
// rebrand would need to touch every site. A central token would
|
|
42
|
+
// collapse the migration to one definition.
|
|
43
|
+
const BUBBLE_BG_BY_VARIANT: Record<BubbleVariant, string> = {
|
|
44
|
+
dark: 'bg-[#121110]',
|
|
45
|
+
light: 'bg-[#e9eaed]',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const BUBBLE_TEXT_BY_VARIANT: Record<BubbleVariant, string> = {
|
|
49
|
+
dark: 'text-white',
|
|
50
|
+
light: 'text-[#080707]',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BUBBLE_BORDER_BY_VARIANT: Record<BubbleVariant, string> = {
|
|
54
|
+
dark: 'border-white/[0.08]',
|
|
55
|
+
light: 'border-black/[0.08]',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Per-corner rounding tables that mirror stream-chat-react's default
|
|
60
|
+
* bubble grouping. Sender bubbles cluster against the right edge so
|
|
61
|
+
* the right corners get flattened in a run; receiver bubbles cluster
|
|
62
|
+
* against the left edge so the left corners get flattened.
|
|
63
|
+
*
|
|
64
|
+
* Rounded corners use `--str-chat__border-radius-md` (18px) and
|
|
65
|
+
* flattened corners use `--str-chat__border-radius-sm` (4px) — same
|
|
66
|
+
* tokens stream's stylesheet uses for `.str-chat__message-bubble`
|
|
67
|
+
* inside a `--first` / `--group` / `--end` wrapper.
|
|
68
|
+
*/
|
|
69
|
+
type BubbleSide = 'sender' | 'receiver'
|
|
70
|
+
|
|
71
|
+
const sideForVariant = (variant: BubbleVariant): BubbleSide =>
|
|
72
|
+
variant === 'dark' ? 'sender' : 'receiver'
|
|
73
|
+
|
|
74
|
+
const CORNER_CLASSES_BY_SIDE_AND_POSITION: Record<
|
|
75
|
+
BubbleSide,
|
|
76
|
+
Record<BubbleGroupPosition, string>
|
|
77
|
+
> = {
|
|
78
|
+
sender: {
|
|
79
|
+
single:
|
|
80
|
+
'rounded-tl-[18px] rounded-tr-[18px] rounded-bl-[18px] rounded-br-[18px]',
|
|
81
|
+
first:
|
|
82
|
+
'rounded-tl-[18px] rounded-tr-[18px] rounded-bl-[18px] rounded-br-[4px]',
|
|
83
|
+
middle:
|
|
84
|
+
'rounded-tl-[18px] rounded-tr-[4px] rounded-bl-[18px] rounded-br-[4px]',
|
|
85
|
+
end: 'rounded-tl-[18px] rounded-tr-[4px] rounded-bl-[18px] rounded-br-[18px]',
|
|
86
|
+
},
|
|
87
|
+
receiver: {
|
|
88
|
+
single:
|
|
89
|
+
'rounded-tl-[18px] rounded-tr-[18px] rounded-bl-[18px] rounded-br-[18px]',
|
|
90
|
+
first:
|
|
91
|
+
'rounded-tl-[18px] rounded-tr-[18px] rounded-bl-[4px] rounded-br-[18px]',
|
|
92
|
+
middle:
|
|
93
|
+
'rounded-tl-[4px] rounded-tr-[18px] rounded-bl-[4px] rounded-br-[18px]',
|
|
94
|
+
end: 'rounded-tl-[4px] rounded-tr-[18px] rounded-bl-[18px] rounded-br-[18px]',
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Chat-bubble container shared by every `MessageAttachment.*` card.
|
|
100
|
+
*
|
|
101
|
+
* Holds the attachment slot (image / video / row) and an optional
|
|
102
|
+
* `text` caption rendered below it — mirrors the mobile chat layout
|
|
103
|
+
* where a sender can attach a caption alongside an attachment ('Here
|
|
104
|
+
* is the file', 'Here is the image').
|
|
105
|
+
*
|
|
106
|
+
* Two visual variants:
|
|
107
|
+
* - `dark` — sender-side (Composer / Sent), white text on `#121110`.
|
|
108
|
+
* - `light` — recipient-side (Received), dark text on `#e9eaed`.
|
|
109
|
+
*
|
|
110
|
+
* Background colors, padding, border-radius, and inherited font come
|
|
111
|
+
* from the stream-chat-react `.str-chat__message-bubble` token set so
|
|
112
|
+
* attachments line up with the `CustomMessage` text bubbles in a real
|
|
113
|
+
* conversation. Padding is uniform across every variant — text rows,
|
|
114
|
+
* audio rows, document rows, and media bubbles all get the same
|
|
115
|
+
* 8px / 16px inset, with media inside getting its own rounded corners.
|
|
116
|
+
*/
|
|
117
|
+
const Bubble: React.FC<BubbleProps> = ({
|
|
118
|
+
variant,
|
|
119
|
+
text,
|
|
120
|
+
bordered = true,
|
|
121
|
+
groupPosition = 'single',
|
|
122
|
+
className,
|
|
123
|
+
children,
|
|
124
|
+
'data-testid': dataTestId,
|
|
125
|
+
}) => {
|
|
126
|
+
const hasText = text != null && text !== ''
|
|
127
|
+
const cornerClasses =
|
|
128
|
+
CORNER_CLASSES_BY_SIDE_AND_POSITION[sideForVariant(variant)][groupPosition]
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
data-testid={dataTestId}
|
|
133
|
+
data-group-position={groupPosition}
|
|
134
|
+
className={classNames(
|
|
135
|
+
// 280px-wide bubble — matches the mobile chat attachment width
|
|
136
|
+
// and keeps the document / image / audio bubbles visually
|
|
137
|
+
// consistent inside the conversation timeline. The 8px / 16px
|
|
138
|
+
// inset matches `--str-chat__spacing-2 --str-chat__spacing-4`
|
|
139
|
+
// so attachments share the same hit / negative-space rhythm
|
|
140
|
+
// as the surrounding `CustomMessage` text bubbles.
|
|
141
|
+
'relative w-[280px] overflow-hidden px-2 py-2',
|
|
142
|
+
cornerClasses,
|
|
143
|
+
BUBBLE_BG_BY_VARIANT[variant],
|
|
144
|
+
BUBBLE_TEXT_BY_VARIANT[variant],
|
|
145
|
+
bordered && 'border',
|
|
146
|
+
bordered && BUBBLE_BORDER_BY_VARIANT[variant],
|
|
147
|
+
className
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
|
|
152
|
+
{hasText ? (
|
|
153
|
+
<p
|
|
154
|
+
className={classNames(
|
|
155
|
+
// No `text-*` / `font-*` overrides here — caption inherits
|
|
156
|
+
// the same font family + size as `.str-chat__message-text`
|
|
157
|
+
// so it matches the surrounding `CustomMessage` bubbles.
|
|
158
|
+
'whitespace-pre-wrap break-words leading-snug',
|
|
159
|
+
// Top gutter only — bubble's `py-2` already supplies the
|
|
160
|
+
// bottom inset, and the children above already render
|
|
161
|
+
// flush against their own bottom edge.
|
|
162
|
+
'pt-2',
|
|
163
|
+
'px-2'
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{text}
|
|
167
|
+
</p>
|
|
168
|
+
) : null}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export default Bubble
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import classNames from 'classnames'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import { renderTypeIcon } from '../../AttachmentCard'
|
|
5
|
+
import type { BubbleVariant } from '../types'
|
|
6
|
+
|
|
7
|
+
import { buildCompactMetaLabel } from './fileMeta'
|
|
8
|
+
|
|
9
|
+
export interface CompactDocumentRowProps {
|
|
10
|
+
variant: BubbleVariant
|
|
11
|
+
/** Filename — drives the title (when no `title` set) and the `EXT` label. */
|
|
12
|
+
filename?: string
|
|
13
|
+
/** Override displayed title. Defaults to `filename`. */
|
|
14
|
+
title?: string
|
|
15
|
+
mimeType?: string
|
|
16
|
+
fileSize?: number
|
|
17
|
+
/**
|
|
18
|
+
* When set, the icon + filename area becomes a `<button>` that fires
|
|
19
|
+
* this handler. Lets the row's primary activation (e.g. opening the
|
|
20
|
+
* PDF viewer) live separately from the trailing slot's own button
|
|
21
|
+
* (e.g. a download icon) without nesting `<button>` inside `<button>`.
|
|
22
|
+
* When omitted, the row renders as a plain presentational `<div>`
|
|
23
|
+
* and the caller is responsible for any outer activation wrapper.
|
|
24
|
+
*/
|
|
25
|
+
onActivate?: () => void
|
|
26
|
+
/**
|
|
27
|
+
* Required when `onActivate` is set. Visible to assistive tech as
|
|
28
|
+
* the inner button's label (e.g. `'Open ESOP-summary.pdf'`).
|
|
29
|
+
*/
|
|
30
|
+
activateLabel?: string
|
|
31
|
+
/**
|
|
32
|
+
* Optional trailing slot — sits outside the activation button so it
|
|
33
|
+
* can host its own interactive controls (e.g. a download `<button>`,
|
|
34
|
+
* the Composer's `DismissButton`) without nesting one button inside
|
|
35
|
+
* another.
|
|
36
|
+
*/
|
|
37
|
+
trailingAction?: React.ReactNode
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Meta line + icon tile are tinted relative to the bubble background,
|
|
41
|
+
// so they stay variant-aware. The title inherits the bubble's text
|
|
42
|
+
// color (set on the parent `Bubble`).
|
|
43
|
+
//
|
|
44
|
+
// `text-black/55` on the `#e9eaed` light bubble lands right at the
|
|
45
|
+
// WCAG AA 4.5:1 small-text threshold (≈4.5:1) — bump to `/65` so
|
|
46
|
+
// the meta line has a comfortable margin on the light bubble. The
|
|
47
|
+
// dark variant (`text-white/55` on `#121110`) already passes AA at
|
|
48
|
+
// ~6:1, so we leave it alone.
|
|
49
|
+
const META_CLASS_BY_VARIANT: Record<BubbleVariant, string> = {
|
|
50
|
+
dark: 'text-white/55',
|
|
51
|
+
light: 'text-black/65',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ICON_BG_BY_VARIANT: Record<BubbleVariant, string> = {
|
|
55
|
+
dark: 'bg-white/10',
|
|
56
|
+
light: 'bg-black/5',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ICON_COLOR_BY_VARIANT: Record<BubbleVariant, string> = {
|
|
60
|
+
dark: 'text-white/85',
|
|
61
|
+
light: 'text-black/85',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compact icon-row used by `Pdf` / `File` `MessageAttachment` variants.
|
|
66
|
+
* Mirrors the mobile chat document attachment from
|
|
67
|
+
* `EARN-8448/pr-2-chat-bubbles-and-file-icons` — a 40x40 typed icon
|
|
68
|
+
* tile on the left, filename + `EXT · SIZE` meta on the right.
|
|
69
|
+
*
|
|
70
|
+
* The row is always wrapped by `Bubble`, which contributes the bubble
|
|
71
|
+
* background, padding, border, and (when the parent wires it up) the
|
|
72
|
+
* click target.
|
|
73
|
+
*/
|
|
74
|
+
const CompactDocumentRow: React.FC<CompactDocumentRowProps> = ({
|
|
75
|
+
variant,
|
|
76
|
+
filename,
|
|
77
|
+
title,
|
|
78
|
+
mimeType = 'application/octet-stream',
|
|
79
|
+
fileSize,
|
|
80
|
+
onActivate,
|
|
81
|
+
activateLabel,
|
|
82
|
+
trailingAction,
|
|
83
|
+
}) => {
|
|
84
|
+
const resolvedTitle = title ?? filename ?? 'File'
|
|
85
|
+
const metaLabel = buildCompactMetaLabel(mimeType, filename, fileSize)
|
|
86
|
+
|
|
87
|
+
const iconTile = (
|
|
88
|
+
<div
|
|
89
|
+
className={classNames(
|
|
90
|
+
'flex size-10 shrink-0 items-center justify-center rounded-sm',
|
|
91
|
+
ICON_BG_BY_VARIANT[variant]
|
|
92
|
+
)}
|
|
93
|
+
aria-hidden
|
|
94
|
+
>
|
|
95
|
+
{renderTypeIcon(mimeType, {
|
|
96
|
+
className: classNames('size-6', ICON_COLOR_BY_VARIANT[variant]),
|
|
97
|
+
weight: 'regular',
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const textBlock = (
|
|
103
|
+
<div className="flex min-w-0 flex-1 flex-col text-left">
|
|
104
|
+
<p className="truncate font-medium leading-snug">{resolvedTitle}</p>
|
|
105
|
+
{metaLabel ? (
|
|
106
|
+
<p
|
|
107
|
+
className={classNames(
|
|
108
|
+
'truncate text-xs leading-4',
|
|
109
|
+
META_CLASS_BY_VARIANT[variant]
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
{metaLabel}
|
|
113
|
+
</p>
|
|
114
|
+
) : null}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// When the row is activatable, its body (icon + text) becomes the
|
|
119
|
+
// click target — wrapping it in a `<button>` rather than wrapping
|
|
120
|
+
// the entire row preserves the trailing slot for its own buttons
|
|
121
|
+
// (download / dismiss) instead of nesting them inside one.
|
|
122
|
+
const body = onActivate ? (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={onActivate}
|
|
126
|
+
aria-label={activateLabel}
|
|
127
|
+
className={classNames(
|
|
128
|
+
'flex min-w-0 flex-1 items-center gap-3 rounded-sm text-left transition-colors',
|
|
129
|
+
variant === 'dark'
|
|
130
|
+
? 'hover:bg-white/[0.04]'
|
|
131
|
+
: 'hover:bg-black/[0.04]'
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{iconTile}
|
|
135
|
+
{textBlock}
|
|
136
|
+
</button>
|
|
137
|
+
) : (
|
|
138
|
+
<>
|
|
139
|
+
{iconTile}
|
|
140
|
+
{textBlock}
|
|
141
|
+
</>
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="flex items-center gap-3 px-3 py-2">
|
|
146
|
+
{body}
|
|
147
|
+
{trailingAction ? <div className="shrink-0">{trailingAction}</div> : null}
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default CompactDocumentRow
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { XIcon } from '@phosphor-icons/react'
|
|
2
|
+
import classNames from 'classnames'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
export interface DismissButtonProps {
|
|
6
|
+
onClick: () => void
|
|
7
|
+
/**
|
|
8
|
+
* `'overlay'` — translucent button sitting on top of media (image /
|
|
9
|
+
* video corner). `'inline'` — opaque button placed in the row of a
|
|
10
|
+
* compact attachment alongside other content.
|
|
11
|
+
*/
|
|
12
|
+
variant?: 'overlay' | 'inline'
|
|
13
|
+
ariaLabel?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DismissButton: React.FC<DismissButtonProps> = ({
|
|
17
|
+
onClick,
|
|
18
|
+
variant = 'overlay',
|
|
19
|
+
ariaLabel = 'Dismiss attachment',
|
|
20
|
+
}) => (
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
onClick={(e) => {
|
|
24
|
+
e.stopPropagation()
|
|
25
|
+
onClick()
|
|
26
|
+
}}
|
|
27
|
+
aria-label={ariaLabel}
|
|
28
|
+
className={classNames(
|
|
29
|
+
'flex size-6 items-center justify-center rounded-full text-white',
|
|
30
|
+
variant === 'overlay'
|
|
31
|
+
? 'bg-[#121110]/85 backdrop-blur'
|
|
32
|
+
: 'bg-white/15 hover:bg-white/25'
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
<XIcon className="size-3" weight="bold" />
|
|
36
|
+
</button>
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
export default DismissButton
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CircleNotchIcon,
|
|
3
|
+
DownloadSimpleIcon,
|
|
4
|
+
IconProps,
|
|
5
|
+
} from '@phosphor-icons/react'
|
|
6
|
+
import classNames from 'classnames'
|
|
7
|
+
import React, { useCallback, useState } from 'react'
|
|
8
|
+
|
|
9
|
+
import { triggerDownload } from './triggerDownload'
|
|
10
|
+
|
|
11
|
+
export type DownloadActionVariant =
|
|
12
|
+
/** Solid pill button used inside compact / file rows. */
|
|
13
|
+
| 'pill'
|
|
14
|
+
/** Round translucent overlay button used over media (image / video viewers). */
|
|
15
|
+
| 'overlay'
|
|
16
|
+
/** Flat icon button used in viewer toolbars. */
|
|
17
|
+
| 'toolbar'
|
|
18
|
+
/**
|
|
19
|
+
* Compact round icon button sized for the trailing slot of a
|
|
20
|
+
* `CompactDocumentRow` (PDF / File rows). Adopts the row's tone so
|
|
21
|
+
* it sits inline next to the filename without competing with it.
|
|
22
|
+
*/
|
|
23
|
+
| 'inline'
|
|
24
|
+
|
|
25
|
+
export interface DownloadActionProps {
|
|
26
|
+
url: string
|
|
27
|
+
filename?: string
|
|
28
|
+
variant?: DownloadActionVariant
|
|
29
|
+
/**
|
|
30
|
+
* Override the visible label on `pill` variants. Defaults to
|
|
31
|
+
* `'Download'`. Hidden on `overlay` / `toolbar` variants.
|
|
32
|
+
*/
|
|
33
|
+
label?: string
|
|
34
|
+
/** Hide the label, keeping just the icon. Defaults to `true` for non-pill variants. */
|
|
35
|
+
iconOnly?: boolean
|
|
36
|
+
/** Tone of the surface. Used by the `pill` variant. */
|
|
37
|
+
tone?: 'dark' | 'light'
|
|
38
|
+
/**
|
|
39
|
+
* Triggered after the download starts so consumers can fire analytics
|
|
40
|
+
* or close a viewer. Errors during download don't suppress the call.
|
|
41
|
+
*/
|
|
42
|
+
onTriggered?: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DownloadAction: React.FC<DownloadActionProps> = ({
|
|
46
|
+
url,
|
|
47
|
+
filename,
|
|
48
|
+
variant = 'pill',
|
|
49
|
+
label = 'Download',
|
|
50
|
+
iconOnly,
|
|
51
|
+
tone = 'dark',
|
|
52
|
+
onTriggered,
|
|
53
|
+
}) => {
|
|
54
|
+
const [busy, setBusy] = useState(false)
|
|
55
|
+
|
|
56
|
+
const handleClick = useCallback(
|
|
57
|
+
(e: React.MouseEvent) => {
|
|
58
|
+
e.stopPropagation()
|
|
59
|
+
if (busy) return
|
|
60
|
+
setBusy(true)
|
|
61
|
+
triggerDownload(url, filename)
|
|
62
|
+
.catch(() => {
|
|
63
|
+
/* swallowed — fallback path inside `triggerDownload` */
|
|
64
|
+
})
|
|
65
|
+
.finally(() => {
|
|
66
|
+
setBusy(false)
|
|
67
|
+
onTriggered?.()
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
[busy, url, filename, onTriggered]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const showIconOnly = iconOnly ?? variant !== 'pill'
|
|
74
|
+
|
|
75
|
+
const iconClass = classNames(
|
|
76
|
+
variant === 'pill' ? 'size-4' : 'size-5',
|
|
77
|
+
'shrink-0'
|
|
78
|
+
)
|
|
79
|
+
const iconProps: IconProps = { className: iconClass, weight: 'bold' }
|
|
80
|
+
|
|
81
|
+
if (variant === 'inline') {
|
|
82
|
+
// Sized to match the existing trailing slot used by `DismissButton`
|
|
83
|
+
// and the decorative download span in `FileAttachment`. Tone keys
|
|
84
|
+
// off the surrounding `Bubble` variant so a sender (dark) bubble
|
|
85
|
+
// gets a lighter icon and a receiver (light) bubble gets a darker
|
|
86
|
+
// one — same approach `CompactDocumentRow` already uses for the
|
|
87
|
+
// type icon.
|
|
88
|
+
const inlineToneClasses: Record<'dark' | 'light', string> = {
|
|
89
|
+
dark: 'text-white/70 hover:bg-white/[0.08] hover:text-white',
|
|
90
|
+
light: 'text-black/70 hover:bg-black/[0.08] hover:text-black',
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={handleClick}
|
|
96
|
+
disabled={busy}
|
|
97
|
+
aria-label={label}
|
|
98
|
+
className={classNames(
|
|
99
|
+
'flex size-8 shrink-0 items-center justify-center rounded-full transition-colors disabled:opacity-70',
|
|
100
|
+
inlineToneClasses[tone]
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{busy ? (
|
|
104
|
+
<CircleNotchIcon
|
|
105
|
+
className="size-4 animate-spin"
|
|
106
|
+
weight="bold"
|
|
107
|
+
aria-hidden
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<DownloadSimpleIcon
|
|
111
|
+
className="size-5 shrink-0"
|
|
112
|
+
weight="bold"
|
|
113
|
+
aria-hidden
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
</button>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (variant === 'pill') {
|
|
121
|
+
return (
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={handleClick}
|
|
125
|
+
disabled={busy}
|
|
126
|
+
aria-label={showIconOnly ? label : undefined}
|
|
127
|
+
className={classNames(
|
|
128
|
+
'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full px-4 text-sm font-medium leading-none transition-colors disabled:opacity-70',
|
|
129
|
+
tone === 'dark'
|
|
130
|
+
? 'bg-[#121110] text-white hover:bg-[#2a2928]'
|
|
131
|
+
: 'bg-white text-[#121110] hover:bg-white/90'
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{busy ? (
|
|
135
|
+
<CircleNotchIcon
|
|
136
|
+
className="size-4 animate-spin"
|
|
137
|
+
weight="bold"
|
|
138
|
+
aria-hidden
|
|
139
|
+
/>
|
|
140
|
+
) : (
|
|
141
|
+
<DownloadSimpleIcon {...iconProps} aria-hidden />
|
|
142
|
+
)}
|
|
143
|
+
{showIconOnly ? null : label}
|
|
144
|
+
</button>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// overlay / toolbar — round translucent button with just the icon
|
|
149
|
+
return (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={handleClick}
|
|
153
|
+
disabled={busy}
|
|
154
|
+
aria-label={label}
|
|
155
|
+
className={classNames(
|
|
156
|
+
'flex size-10 shrink-0 items-center justify-center rounded-full text-white transition-colors disabled:opacity-70',
|
|
157
|
+
variant === 'overlay'
|
|
158
|
+
? 'bg-black/55 backdrop-blur hover:bg-black/70'
|
|
159
|
+
: 'bg-white/10 hover:bg-white/20'
|
|
160
|
+
)}
|
|
161
|
+
>
|
|
162
|
+
{busy ? (
|
|
163
|
+
<CircleNotchIcon
|
|
164
|
+
className="size-5 animate-spin"
|
|
165
|
+
weight="bold"
|
|
166
|
+
aria-hidden
|
|
167
|
+
/>
|
|
168
|
+
) : (
|
|
169
|
+
<DownloadSimpleIcon {...iconProps} aria-hidden />
|
|
170
|
+
)}
|
|
171
|
+
</button>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default DownloadAction
|