@linktr.ee/messaging-react 3.30.0 → 3.31.0-rc-1785892270

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/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-CiqbKUJW.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageBubble=e.MessageBubble;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-CcitzVoW.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageBubble=e.MessageBubble;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as p, m as d, n as v, p as C, q as A, s as k, t as F, u as b, v as P, w as f, x, o as y, y as z, z as B, B as S, D as q, E as D, G as E, H as V, I as w } from "./index-BpDW7z5O.js";
1
+ import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as p, m as d, n as v, p as C, q as A, s as k, t as F, u as b, v as P, w as f, x, o as y, y as z, z as B, B as S, D as q, E as D, G as E, H as V, I as w } from "./index-CN3errpB.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.30.0",
3
+ "version": "3.31.0-rc-1785892270",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@linktr.ee/component-library": "11.8.6",
53
- "@linktr.ee/messaging-core": "^2.2.0",
53
+ "@linktr.ee/messaging-core": "2.3.0-rc-1785892270",
54
54
  "@linktr.ee/messaging-taxonomy": "^0.5.0",
55
55
  "@phosphor-icons/react": "^2.1.10",
56
56
  "culori": "^4.0.2"
@@ -64,6 +64,21 @@ describe('CustomLinkPreviewList', () => {
64
64
  expect(screen.queryByRole('img')).toBeNull()
65
65
  })
66
66
 
67
+ it('is not a scroller of its own: the composer card scrolls as one piece (MES-1037)', () => {
68
+ // The inverted layout contract — see this component's doc comment. While
69
+ // this list scrolled and shrank on its own, a staged preview was cropped to
70
+ // keep the textarea above the keyboard instead of scrolling with it.
71
+ const { container } = stage({
72
+ status: LinkPreviewStatus.LOADED,
73
+ title: 'Example story',
74
+ image_url: 'https://cdn.example.com/story.jpg',
75
+ })
76
+
77
+ const list = container.querySelector('.messaging-link-preview-list')
78
+ expect(list).not.toHaveClass('overflow-y-auto')
79
+ expect(list).not.toHaveClass('min-h-0')
80
+ })
81
+
67
82
  it('is dismiss-only: the staged card never navigates', () => {
68
83
  // The deliberate behaviour change in MES-1349 — see this component's own
69
84
  // doc comment for why (MES-1358 §7 Q7, touch hazard). Asserted so the
@@ -52,14 +52,15 @@ export const CustomLinkPreviewList = () => {
52
52
  if (!showLinkPreviews) return null
53
53
 
54
54
  return (
55
- // Layout contract: this list is the composer's *shrinkable* region. The
56
- // mobile visitor surface sizes itself to the keyboard-shrunk
57
- // visualViewport, so when vertical space runs out this container gives
58
- // way (min-h-0) and scrolls, while the textarea + send row stays visible
59
- // (shrink-0 in CustomMessageInput). Each card is wrapped in a shrink-0
60
- // block so it keeps its intrinsic height and the container scrolls — a
61
- // bare flex-column child would be shrunk on the column axis and its
62
- // fixed-height hero would spill out of the collapsed card box.
55
+ // Layout contract: this list is NOT a scrollable or shrinkable region. It
56
+ // used to be the composer's one squeezable box, giving way (min-h-0) and
57
+ // scrolling on its own so the textarea row could stay pinned above the
58
+ // keyboard. That cropped the preview to make room for the input, which read
59
+ // as the preview being cut off; MES-1037 inverted it. The composer card
60
+ // (`.central-container`) is now the single capped scroll container and this
61
+ // list scrolls as one piece with the textarea inside it, so the preview
62
+ // always keeps its full height and only the visible slice of it changes.
63
+ // Do not reintroduce a nested scroller or a min-h-0 here.
63
64
  //
64
65
  // Horizontally the card does NOT shrink, deliberately. `CardShell` bakes
65
66
  // Figma's `w-[290px]` and there is exactly one width, in the toolkit
@@ -89,12 +90,12 @@ export const CustomLinkPreviewList = () => {
89
90
  // viewport. That is the whole trade: deform every narrow surface, or leave
90
91
  // one sub-floor device class awkward.
91
92
  //
92
- // It is awkward, not broken. Setting only `overflow-y` promotes the other
93
- // axis, so `overflow-x` computes to `auto` and the region scrolls sideways
94
- // instead of spilling; at 320px the dismiss button's right edge lands ~6px
95
- // past the fold, reachable by scrolling. That residue belongs to whoever
96
- // owns the mobile layout contract, not to this component.
97
- <div className="messaging-link-preview-list flex min-h-0 flex-col items-stretch w-full gap-2 overflow-y-auto overscroll-contain">
93
+ // It is awkward, not broken. The card above sets only `overflow-y`, which
94
+ // promotes the other axis, so its `overflow-x` computes to `auto` and it
95
+ // scrolls sideways instead of spilling; at 320px the dismiss button's right
96
+ // edge lands ~6px past the fold, reachable by scrolling. That residue
97
+ // belongs to whoever owns the mobile layout contract, not to this component.
98
+ <div className="messaging-link-preview-list flex flex-col items-stretch w-full gap-2">
98
99
  {stateLinkPreviews.map((linkPreview) => {
99
100
  // A LOADING preview has no `image_url` yet, so keying the layout off
100
101
  // the image alone rendered the skeleton as two bare text bars and then
@@ -104,18 +105,15 @@ export const CustomLinkPreviewList = () => {
104
105
  const isLoading = LinkPreviewsManager.previewIsLoading(linkPreview)
105
106
 
106
107
  return (
107
- <div key={linkPreview.og_scrape_url} className="w-full shrink-0">
108
- <LinkAttachment.Composer
109
- title={linkPreview.title}
110
- url={linkPreview.og_scrape_url}
111
- thumbnailUrl={linkPreview.image_url}
112
- layout={
113
- isLoading || linkPreview.image_url ? 'featured' : 'classic'
114
- }
115
- status={isLoading ? 'loading' : 'ready'}
116
- onDismiss={() => handleDismiss(linkPreview.og_scrape_url)}
117
- />
118
- </div>
108
+ <LinkAttachment.Composer
109
+ key={linkPreview.og_scrape_url}
110
+ title={linkPreview.title}
111
+ url={linkPreview.og_scrape_url}
112
+ thumbnailUrl={linkPreview.image_url}
113
+ layout={isLoading || linkPreview.image_url ? 'featured' : 'classic'}
114
+ status={isLoading ? 'loading' : 'ready'}
115
+ onDismiss={() => handleDismiss(linkPreview.og_scrape_url)}
116
+ />
119
117
  )
120
118
  })}
121
119
  </div>
@@ -6,6 +6,7 @@ import { Channel, Chat, type SendButtonProps } from 'stream-chat-react'
6
6
 
7
7
  import { createMockStreamChatClient } from '../../testing/createMockStreamChatClient'
8
8
  import LockedAttachment from '../CustomMessage/LockedAttachment'
9
+ import LinkAttachment from '../LinkAttachment'
9
10
 
10
11
  import { CustomMessageInput } from '.'
11
12
 
@@ -75,10 +76,16 @@ type WrapperProps = {
75
76
  sendButton?: React.ComponentType<SendButtonProps>
76
77
  attachmentPreviewList?: React.ComponentType
77
78
  renderFooter?: () => React.ReactNode
79
+ /**
80
+ * Stands in for a host that publishes `--messaging-surface-height` — the
81
+ * keyboard-shrunk viewport height the composer caps itself against. Set it to
82
+ * pin the cap to a known value instead of leaving it viewport-relative.
83
+ */
84
+ surfaceHeight?: number
78
85
  }
79
86
 
80
87
  const Wrapper: React.FC<WrapperProps> = (props) => {
81
- const { frozen, renderActions, sendButton, attachmentPreviewList, renderFooter } = props
88
+ const { frozen, renderActions, sendButton, attachmentPreviewList, renderFooter, surfaceHeight } = props
82
89
 
83
90
  const [client] = React.useState(() => createMockStreamChatClient(mockUser))
84
91
 
@@ -97,7 +104,23 @@ const Wrapper: React.FC<WrapperProps> = (props) => {
97
104
  {...(sendButton ? { SendButton: sendButton } : {})}
98
105
  {...(attachmentPreviewList ? { AttachmentPreviewList: attachmentPreviewList } : {})}
99
106
  >
100
- <div className="bg-white" style={{ minWidth: 360 }}>
107
+ <div
108
+ className="bg-white"
109
+ style={
110
+ {
111
+ minWidth: 360,
112
+ // Bottom-anchored inside a fixed-height surface, the way the host
113
+ // lays the composer out under a raised keyboard.
114
+ ...(surfaceHeight && {
115
+ height: surfaceHeight,
116
+ '--messaging-surface-height': `${surfaceHeight}px`,
117
+ display: 'flex',
118
+ flexDirection: 'column',
119
+ justifyContent: 'flex-end',
120
+ }),
121
+ } as React.CSSProperties
122
+ }
123
+ >
101
124
  <CustomMessageInput renderActions={renderActions} renderFooter={renderFooter} />
102
125
  </div>
103
126
  </Channel>
@@ -226,6 +249,31 @@ WithAttachments.parameters = {
226
249
  },
227
250
  }
228
251
 
252
+ export const WithStagedLinkPreviewUnderKeyboard: StoryFn<WrapperProps> = (args) => (
253
+ <Wrapper {...args} />
254
+ )
255
+ WithStagedLinkPreviewUnderKeyboard.args = {
256
+ // Roughly a 393x852 phone with the soft keyboard up, so the 250px staged card
257
+ // alone outgrows the composer's cap (half of this) and the card scrolls.
258
+ surfaceHeight: 516,
259
+ attachmentPreviewList: () => (
260
+ <LinkAttachment.Composer
261
+ title="How to run your first marathon"
262
+ url="https://example.com/marathon"
263
+ thumbnailUrl="https://picsum.photos/seed/marathon/580/300"
264
+ onDismiss={() => console.log('dismiss link preview')}
265
+ />
266
+ ),
267
+ }
268
+ WithStagedLinkPreviewUnderKeyboard.parameters = {
269
+ docs: {
270
+ description: {
271
+ story:
272
+ 'MES-1037: with a link preview staged the composer used to grow tall enough for the soft keyboard to cover the textarea and send button. The white card is now capped at half `--messaging-surface-height` (the keyboard-shrunk height its host publishes) and scrolls as ONE region — the preview keeps its full height instead of being cropped to make room, and the send button sticks to the bottom of the scroll so a long message cannot carry it out of view.',
273
+ },
274
+ },
275
+ }
276
+
229
277
  export const WithPaidAttachment: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
230
278
  WithPaidAttachment.args = {
231
279
  attachmentPreviewList: () => (
@@ -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"