@linktr.ee/messaging-react 3.31.0-rc-1785485102 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/{Card-2l_y20FM.js → Card-CmlaSw7i.js} +2 -2
  2. package/dist/{Card-2l_y20FM.js.map → Card-CmlaSw7i.js.map} +1 -1
  3. package/dist/{Card-Gr4HDVbn.cjs → Card-EWN07XIl.cjs} +2 -2
  4. package/dist/{Card-Gr4HDVbn.cjs.map → Card-EWN07XIl.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-B96WLmx6.js → index-CN3errpB.js} +3055 -3109
  7. package/dist/index-CN3errpB.js.map +1 -0
  8. package/dist/index-CcitzVoW.cjs +5 -0
  9. package/dist/index-CcitzVoW.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +56 -60
  12. package/dist/index.js +1 -1
  13. package/package.json +2 -2
  14. package/src/components/ChannelView.broadcast-name.integration.test.tsx +116 -0
  15. package/src/components/ChannelView.tsx +17 -3
  16. package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +15 -0
  17. package/src/components/CustomLinkPreviewList/index.tsx +24 -26
  18. package/src/components/CustomMessage/BroadcastNameContext.test.ts +43 -0
  19. package/src/components/CustomMessage/BroadcastNameContext.ts +95 -0
  20. package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -22
  21. package/src/components/CustomMessage/CustomMessage.test.tsx +48 -0
  22. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +62 -0
  23. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +8 -2
  24. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
  25. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -8
  26. package/src/components/CustomMessage/index.tsx +23 -3
  27. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +50 -2
  28. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +180 -2
  29. package/src/components/CustomMessageInput/index.tsx +143 -16
  30. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +57 -166
  31. package/src/components/MessageAttachment/Audio/index.tsx +77 -71
  32. package/src/components/MessageAttachment/MessageAttachment.test.tsx +75 -175
  33. package/src/components/MessageAttachment/index.tsx +9 -25
  34. package/src/components/MessageAttachment/types.ts +9 -19
  35. package/src/components/MessagingShell/index.tsx +2 -0
  36. package/src/index.ts +1 -0
  37. package/src/styles.css +5 -120
  38. package/src/types.ts +11 -0
  39. package/src/utils/formatRelativeTime.test.ts +63 -57
  40. package/src/utils/formatRelativeTime.ts +21 -20
  41. package/dist/index-B96WLmx6.js.map +0 -1
  42. package/dist/index-CCtHMrmO.cjs +0 -5
  43. package/dist/index-CCtHMrmO.cjs.map +0 -1
  44. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +0 -335
@@ -37,12 +37,21 @@ vi.mock('stream-chat-react', () => ({
37
37
  <Input />
38
38
  </div>
39
39
  ),
40
+ // `maxRows` is surfaced as a data attribute rather than spread onto the
41
+ // <textarea> (which React would warn about) so a test can still assert it —
42
+ // stripping it silently would hide the clamp that MES-1037 removed.
40
43
  TextareaComposer: ({
41
- maxRows: _maxRows,
44
+ maxRows,
42
45
  ...props
43
46
  }: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
44
47
  maxRows?: number
45
- }) => <textarea data-testid="textarea-composer" {...props} />,
48
+ }) => (
49
+ <textarea
50
+ data-testid="textarea-composer"
51
+ data-max-rows={String(maxRows)}
52
+ {...props}
53
+ />
54
+ ),
46
55
  AttachmentPreviewList: () => <div data-testid="attachment-preview-list" />,
47
56
  QuotedMessagePreview: () => <div data-testid="quoted-message-preview" />,
48
57
  useChannelStateContext: () => ({
@@ -318,6 +327,175 @@ describe('CustomMessageInput', () => {
318
327
  expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
319
328
  })
320
329
 
330
+ // MES-1037: a staged link preview made the composer tall enough for the soft
331
+ // keyboard to cover the textarea and the send button. The card now caps itself
332
+ // against the keyboard-shrunk surface and scrolls as one region.
333
+ //
334
+ // None of the *layout* can be verified here — jsdom has no layout engine, so
335
+ // no sticky, no overflow, and the polyfilled ResizeObserver measures
336
+ // getBoundingClientRect, which jsdom always reports as zero. These are
337
+ // regression guards on the classes and the observer's behaviour; the layout
338
+ // itself was verified in real Chrome over the DevTools protocol.
339
+ describe('keeps the composer clear of the soft keyboard (MES-1037)', () => {
340
+ let resizeCard: (() => void) | undefined
341
+
342
+ beforeEach(() => {
343
+ mockChannelData = {}
344
+ resizeCard = undefined
345
+ vi.stubGlobal(
346
+ 'ResizeObserver',
347
+ class {
348
+ constructor(cb: ResizeObserverCallback) {
349
+ resizeCard = () => cb([], this as unknown as ResizeObserver)
350
+ }
351
+ observe() {}
352
+ unobserve() {}
353
+ disconnect() {}
354
+ }
355
+ )
356
+ })
357
+
358
+ afterEach(() => {
359
+ vi.unstubAllGlobals()
360
+ })
361
+
362
+ const renderCard = () => {
363
+ const { container } = renderWithProviders(<CustomMessageInput />)
364
+ return container.querySelector('.central-container') as HTMLElement
365
+ }
366
+
367
+ it('caps the card against the surface height and scrolls it as one region', () => {
368
+ const card = renderCard()
369
+
370
+ expect(card).toHaveClass(
371
+ 'max-h-[calc(var(--messaging-surface-height,100dvh)*0.5)]',
372
+ 'overflow-y-auto',
373
+ 'overscroll-contain'
374
+ )
375
+ // Nothing inside may shrink, or the previews get cropped to make room for
376
+ // the textarea instead of scrolling with it.
377
+ expect(card).not.toHaveClass('min-h-0')
378
+ expect(card.querySelector('.messaging-composer-previews')).not.toHaveClass(
379
+ 'min-h-0'
380
+ )
381
+ expect(card.querySelector('.messaging-composer-row')).not.toHaveClass(
382
+ 'shrink-0'
383
+ )
384
+ })
385
+
386
+ it('lets the textarea grow to fit rather than scrolling its own overflow', () => {
387
+ const card = renderCard()
388
+
389
+ // TextareaComposer defaults an absent maxRows to 1, so this has to be set.
390
+ expect(screen.getByTestId('textarea-composer')).toHaveAttribute(
391
+ 'data-max-rows',
392
+ 'Infinity'
393
+ )
394
+ // Growing to fit is what keeps it from overflowing; overflow-hidden is
395
+ // what keeps it from becoming a second scrollbar on the occasions the
396
+ // autosize is momentarily out of date (a width change it never hears
397
+ // about, e.g. the card gaining its own scrollbar and rewrapping the text).
398
+ expect(screen.getByTestId('textarea-composer')).toHaveClass(
399
+ 'overflow-hidden'
400
+ )
401
+ // And the card stays the one region that does scroll.
402
+ expect(card).toHaveClass('overflow-y-auto')
403
+ })
404
+
405
+ it('leaves plain-text pastes to the browser but lets file pastes reach Stream', () => {
406
+ renderCard()
407
+
408
+ // Stream's paste handler is dispatched from React's root, above this
409
+ // card, so a listener further up stands in for it: the event reaching
410
+ // there means Stream would have handled it (and preventDefault'd the
411
+ // browser's own insert, which is what leaves the composer unresized).
412
+ const reachedStream = vi.fn()
413
+ document.addEventListener('paste', reachedStream)
414
+
415
+ const paste = (items: Array<{ kind: string }>) => {
416
+ const event = new Event('paste', { bubbles: true, cancelable: true })
417
+ Object.defineProperty(event, 'clipboardData', { value: { items } })
418
+ screen.getByTestId('textarea-composer').dispatchEvent(event)
419
+ }
420
+
421
+ paste([{ kind: 'string' }])
422
+ expect(reachedStream).not.toHaveBeenCalled()
423
+
424
+ paste([{ kind: 'file' }])
425
+ expect(reachedStream).toHaveBeenCalledTimes(1)
426
+
427
+ document.removeEventListener('paste', reachedStream)
428
+ })
429
+
430
+ it('detects a pasted file via clipboardData.items even when .files is empty (Safari)', () => {
431
+ renderCard()
432
+
433
+ // Safari never populates clipboardData.files on paste, even for an
434
+ // image — only .items has it. Checking .files here would silently drop
435
+ // a pasted screenshot instead of handing it to Stream to upload.
436
+ const reachedStream = vi.fn()
437
+ document.addEventListener('paste', reachedStream)
438
+
439
+ const event = new Event('paste', { bubbles: true, cancelable: true })
440
+ Object.defineProperty(event, 'clipboardData', {
441
+ value: { files: [], items: [{ kind: 'file' }] },
442
+ })
443
+ screen.getByTestId('textarea-composer').dispatchEvent(event)
444
+
445
+ expect(reachedStream).toHaveBeenCalledTimes(1)
446
+
447
+ document.removeEventListener('paste', reachedStream)
448
+ })
449
+
450
+ it('scrolls the input back into view when the card shrinks under it', () => {
451
+ const card = renderCard()
452
+ // jsdom has no layout, so stand in for a card whose cap now cuts the
453
+ // input off below the fold.
454
+ Object.defineProperty(card, 'scrollHeight', {
455
+ configurable: true,
456
+ value: 380,
457
+ })
458
+ screen.getByTestId('textarea-composer').focus()
459
+
460
+ resizeCard?.()
461
+
462
+ expect(card.scrollTop).toBe(380)
463
+ })
464
+
465
+ it('leaves the scroll position alone when nothing in the composer is focused', () => {
466
+ const card = renderCard()
467
+ Object.defineProperty(card, 'scrollHeight', {
468
+ configurable: true,
469
+ value: 380,
470
+ })
471
+ // The composer autofocuses, so blur to reach the case this guards: a
472
+ // resize with no keyboard up must not yank a visitor reading a preview.
473
+ screen.getByTestId('textarea-composer').blur()
474
+
475
+ resizeCard?.()
476
+
477
+ expect(card.scrollTop).toBe(0)
478
+ })
479
+
480
+ it('sticks the send button to the bottom of the scroll, at its resting inset', () => {
481
+ renderCard()
482
+
483
+ // The offset must match the wrapper's own margin: sticky resolves against
484
+ // the border box, so at bottom-0 the button would lose that inset and sit
485
+ // flush with the card's edge for as long as it stayed stuck. pt-2 gives
486
+ // the same inset above: sticky offsets only the bottom edge, so once the
487
+ // card scrolls far enough for the button to ride up to the top of the
488
+ // row, a staged preview otherwise meets it flush. Asserted together so
489
+ // they cannot drift apart.
490
+ expect(screen.getByTestId('send-button').parentElement).toHaveClass(
491
+ 'sticky',
492
+ 'bottom-2',
493
+ 'mb-2',
494
+ 'pt-2'
495
+ )
496
+ })
497
+ })
498
+
321
499
  // MES-1303: the floating composer overlays the message list, so growing it
322
500
  // reserves space via padding-bottom but never moves the viewport. The resize
323
501
  // observer re-pins the thread to the latest message when the composer grows —
@@ -79,15 +79,96 @@ const CustomMessageInputInner: React.FC = () => {
79
79
  AttachmentPreviewList = DefaultAttachmentPreviewList,
80
80
  } = useComponentContext('CustomMessageInput')
81
81
 
82
+ // Scroll the textarea back into view when the card shrinks under it. Browsers
83
+ // reveal a newly focused element by themselves, and the caret while typing, so
84
+ // neither needs help; the one thing nothing reveals is the box getting shorter
85
+ // underneath the input — which is exactly what the keyboard does here, a beat
86
+ // after the tap that raised it, once the host reports it and the cap below
87
+ // follows the shrunken surface (MES-1037).
88
+ const cardRef = React.useRef<HTMLDivElement>(null)
89
+ React.useEffect(() => {
90
+ const card = cardRef.current
91
+
92
+ if (!card || typeof ResizeObserver === 'undefined') {
93
+ return
94
+ }
95
+
96
+ const observer = new ResizeObserver(() => {
97
+ if (card.contains(document.activeElement)) {
98
+ card.scrollTop = card.scrollHeight
99
+ }
100
+ })
101
+ observer.observe(card)
102
+
103
+ return () => observer.disconnect()
104
+ }, [])
105
+
106
+ // Let the browser insert pasted text itself, so the composer grows to fit it
107
+ // immediately (MES-1037). Stream's paste handler calls preventDefault and
108
+ // feeds the text back through composer state asynchronously; TextareaComposer
109
+ // then assigns `textarea.value` in a layout effect, which fires no input event
110
+ // and so never triggers react-textarea-autosize's re-measure — leaving the
111
+ // composer one line tall, with the pasted text hidden, until the next
112
+ // keystroke happens to resize it. Letting the default action run puts pasted
113
+ // text on exactly the path typed text takes, which measures correctly.
114
+ //
115
+ // It has to be a native listener that stops propagation rather than an
116
+ // `onPaste` prop: TextareaComposer takes `onPaste` from the message-input
117
+ // context and applies it after spreading our props, so ours would be
118
+ // overridden, and a React handler on this card would run after the textarea's
119
+ // anyway. Stopping the event here keeps it from reaching React's root, where
120
+ // Stream's handler is dispatched from. Pastes carrying a file still go to
121
+ // Stream, which uploads it as an attachment.
122
+ React.useEffect(() => {
123
+ const card = cardRef.current
124
+
125
+ if (!card) {
126
+ return
127
+ }
128
+
129
+ const passToBrowser = (event: ClipboardEvent) => {
130
+ // `items`, not `files`: Safari never populates `clipboardData.files` on
131
+ // paste, even for an image, only `items` — which is why Stream's own
132
+ // handler reads files via `items` (`dataTransferItemsToFiles`) rather
133
+ // than `.files`. Checking `.files.length` here would treat a pasted
134
+ // screenshot as plain text on Safari and drop it silently instead of
135
+ // handing it to Stream to upload.
136
+ const hasFile = Array.from(event.clipboardData?.items ?? []).some(
137
+ (item) => item.kind === 'file'
138
+ )
139
+ if (!hasFile) {
140
+ event.stopPropagation()
141
+ }
142
+ }
143
+
144
+ card.addEventListener('paste', passToBrowser)
145
+
146
+ return () => card.removeEventListener('paste', passToBrowser)
147
+ }, [])
148
+
82
149
  return (
83
- // min-h-0 lets the composer shrink under keyboard squeeze (the link
84
- // preview list inside is the scrollable region); the textarea + send row
85
- // is shrink-0 so it can never be pushed out of view. self-stretch matters
86
- // because the parent `.message-input` row uses items-end, which suppresses
87
- // the default cross-axis stretch without it this container keeps its
88
- // content height and the squeeze never reaches the preview list. Stream's
89
- // <MessageInput> renders its Input directly (context providers only, no
90
- // wrapper element), so this is a direct flex item of that row.
150
+ // This card is the composer's ONE scroll container: staged previews and the
151
+ // textarea row scroll together past a cap, instead of the previews being
152
+ // squeezed to keep the row in view (MES-1037). Nothing inside may shrink, or
153
+ // the previews get cropped rather than scrolled — hence no `min-h-0` here or
154
+ // on the previews wrapper, no `shrink-0` on the row, and no nested scroller
155
+ // in CustomLinkPreviewList. Every child keeps its intrinsic height and the
156
+ // overflow lands on this scrollport. `self-stretch` went with the squeeze it
157
+ // fed: the parent `.message-input` row is `items-end`, so the card now sizes
158
+ // to its content up to the cap. Stream's <MessageInput> renders its Input
159
+ // directly (context providers only, no wrapper element), so this is a direct
160
+ // flex item of that row.
161
+ //
162
+ // The cap is half the *keyboard-shrunk* surface height, because a cap that
163
+ // doesn't shrink with the keyboard lets the card spill past the visible
164
+ // viewport — which is the bug. No CSS unit tracks the visual viewport, so
165
+ // `--messaging-surface-height` is an optional host contract, like this
166
+ // package's own `--messaging-composer-height`: linktr.ee-profiles publishes
167
+ // it on the messaging surface (an ancestor of this composer at runtime) from
168
+ // its own `visualViewport` measurement, and it inherits in. Hosts that don't
169
+ // fall back to `100dvh` — bounded, but not keyboard-aware. Deliberately not
170
+ // measured here: the host owns viewport control and the package must not
171
+ // fight it for it.
91
172
  //
92
173
  // The container itself carries no padding of its own: it used to (p-2),
93
174
  // but that inset now lives on the textarea as padding instead, so the
@@ -102,18 +183,22 @@ const CustomMessageInputInner: React.FC = () => {
102
183
  // `[&>:not(...)]:p-2` variant restores the container's old inset for any
103
184
  // such foreign direct child, without re-adding it to our own wrappers.
104
185
  <div
105
- className="central-container flex min-h-0 flex-col min-w-0 w-full self-stretch bg-white rounded-[1.5rem] shadow-[0_4px_16px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.04)] [&>:not(.messaging-composer-previews):not(.messaging-composer-row)]:p-2"
186
+ ref={cardRef}
187
+ className="central-container flex flex-col min-w-0 w-full max-h-[calc(var(--messaging-surface-height,100dvh)*0.5)] overflow-y-auto overscroll-contain bg-white rounded-[1.5rem] shadow-[0_4px_16px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.04)] [&>:not(.messaging-composer-previews):not(.messaging-composer-row)]:p-2"
106
188
  data-dd-privacy="mask"
107
189
  >
108
190
  {/* empty:hidden collapses this row (padding, gap, and all) when none of
109
191
  the previews below have anything to render, so an idle composer has
110
- no leftover top inset before the textarea row. */}
111
- <div className="messaging-composer-previews empty:hidden flex min-h-0 flex-col gap-2 px-2 pt-2">
192
+ no leftover top inset before the textarea row. No bottom inset: the
193
+ textarea's own py-3.5 already separates the last preview from the
194
+ text, and adding one here double-spaces them now that both sit in the
195
+ same scroll. */}
196
+ <div className="messaging-composer-previews empty:hidden flex flex-col gap-2 px-2 pt-2">
112
197
  <QuotedMessagePreview />
113
198
  <CustomLinkPreviewList />
114
199
  <AttachmentPreviewList />
115
200
  </div>
116
- <div className="messaging-composer-row flex shrink-0 items-start">
201
+ <div className="messaging-composer-row flex items-start">
117
202
  <TextareaComposer
118
203
  aria-disabled={disabled || undefined}
119
204
  // TextareaComposer wraps the real <textarea> in its own div, so
@@ -125,7 +210,19 @@ const CustomMessageInputInner: React.FC = () => {
125
210
  containerClassName="min-w-0 flex-1 leading-[0]"
126
211
  // bg-transparent: the container has no padding to inset it behind
127
212
  // anymore, so an opaque textarea would paint over its rounded corners.
128
- className="w-full resize-none bg-transparent py-3.5 pl-4 pr-4 outline-none leading-5 placeholder:text-black/30 text-sm"
213
+ //
214
+ // overflow-hidden makes "the card is the only scrollable region" a
215
+ // property of the CSS rather than of the autosize arithmetic always
216
+ // landing exactly right. A textarea's UA default is `overflow: auto`,
217
+ // and TextareaComposer sizes itself by copying the computed style into
218
+ // a detached measuring textarea on input, window resize and font load
219
+ // only — so any width change it does not hear about (most obviously
220
+ // this card gaining its own scrollbar, which narrows the textarea and
221
+ // rewraps the text onto another line) leaves it a line short and
222
+ // sprouts a second scrollbar inside the first. The caret stays visible
223
+ // either way: browsers still scroll an overflow-hidden textarea to
224
+ // reveal it, and the next keystroke re-fits the height.
225
+ className="w-full resize-none overflow-hidden bg-transparent py-3.5 pl-4 pr-4 outline-none leading-5 placeholder:text-black/30 text-sm"
129
226
  // While this might usually be considered an anti-pattern, in most
130
227
  // cases, when a message thread is rendered, we want the input to
131
228
  // gain focus automatically.
@@ -134,7 +231,12 @@ const CustomMessageInputInner: React.FC = () => {
134
231
  // Enter sends (Shift+Enter inserts a newline), so label the
135
232
  // on-screen keyboard's return key accordingly.
136
233
  enterKeyHint="send"
137
- maxRows={4}
234
+ // Grow to fit every line rather than scrolling the overflow inside
235
+ // the textarea past a few of them: the card above is the one scroll
236
+ // container, so a message long enough to outgrow it scrolls there, as
237
+ // one piece with the previews. Explicit because TextareaComposer
238
+ // defaults an absent `maxRows` to 1, not to "no cap".
239
+ maxRows={Infinity}
138
240
  readOnly={disabled}
139
241
  tabIndex={disabled ? -1 : undefined}
140
242
  />
@@ -143,8 +245,33 @@ const CustomMessageInputInner: React.FC = () => {
143
245
  grows — lives on a wrapper instead of the button's own
144
246
  className: a host-supplied SendButton can (and, per our own
145
247
  stories, does) spread its own className over the one we pass in,
146
- which would otherwise silently drop this spacing. */}
147
- <div className="mt-auto mb-2 mr-2 shrink-0">
248
+ which would otherwise silently drop this spacing. The sticky is
249
+ here for the same reason.
250
+
251
+ sticky: mt-auto alone anchors the button to the bottom of a row the
252
+ textarea can grow taller than the card, carrying send out of the
253
+ scroll's view while you are still typing. Sticking it to the
254
+ scrollport keeps it reachable without a scroll, floating over the
255
+ textarea's own column, so nothing reflows and no text runs under it.
256
+ The offset matches mb-2 because sticky resolves against the border
257
+ box, not the margin box: at bottom-0 the button would drop that
258
+ inset and sit flush with the card's edge for as long as it stayed
259
+ stuck. A sticky element cannot leave its containing block, which is
260
+ the row — so scrolling all the way up to the previews takes the
261
+ button off-screen with the row it belongs to, and it is pinned at
262
+ every position where the row is in view, i.e. whenever you are
263
+ typing.
264
+
265
+ pt-2 gives the button the same inset above it that mb-2 gives below,
266
+ and keeps it there while the card scrolls: sticky offsets only the
267
+ bottom edge, so once the card scrolls far enough for the button to
268
+ ride up to the top of the row, a staged preview would otherwise meet
269
+ it flush. The padding travels with the button, so the gap holds at
270
+ every scroll position rather than only at rest. It needs no
271
+ background to sit on: sticky cannot leave its containing block, so
272
+ the button stays inside the row and a preview never passes behind
273
+ it. */}
274
+ <div className="sticky bottom-2 mt-auto mb-2 mr-2 shrink-0 pt-2">
148
275
  <SendButton
149
276
  sendMessage={handleSubmit}
150
277
  aria-label="Send"