@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/{Card-CSCaUkmR.js → Card-CmlaSw7i.js} +2 -2
- package/dist/{Card-CSCaUkmR.js.map → Card-CmlaSw7i.js.map} +1 -1
- package/dist/{Card-C1eu6t5N.cjs → Card-EWN07XIl.cjs} +2 -2
- package/dist/{Card-C1eu6t5N.cjs.map → Card-EWN07XIl.cjs.map} +1 -1
- package/dist/{index-BpDW7z5O.js → index-CN3errpB.js} +1360 -1326
- package/dist/{index-BpDW7z5O.js.map → index-CN3errpB.js.map} +1 -1
- package/dist/{index-CiqbKUJW.cjs → index-CcitzVoW.cjs} +3 -3
- package/dist/{index-CiqbKUJW.cjs.map → index-CcitzVoW.cjs.map} +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +15 -0
- package/src/components/CustomLinkPreviewList/index.tsx +24 -26
- package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +50 -2
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +180 -2
- package/src/components/CustomMessageInput/index.tsx +143 -16
package/dist/index.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-
|
|
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-
|
|
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.
|
|
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": "
|
|
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
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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.
|
|
93
|
-
// axis, so `overflow-x` computes to `auto` and
|
|
94
|
-
// instead of spilling; at 320px the dismiss button's right
|
|
95
|
-
// past the fold, reachable by scrolling. That residue
|
|
96
|
-
// owns the mobile layout contract, not to this component.
|
|
97
|
-
<div className="messaging-link-preview-list flex
|
|
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
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
44
|
+
maxRows,
|
|
42
45
|
...props
|
|
43
46
|
}: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|
44
47
|
maxRows?: number
|
|
45
|
-
}) =>
|
|
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
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// the
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|