@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
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-CCtHMrmO.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.d.ts CHANGED
@@ -33,24 +33,37 @@ export declare type AttachmentSourceType = 'image' | 'audio' | 'video' | 'docume
33
33
  declare interface AudioAttachmentSharedProps extends MessageAttachmentBaseProps {
34
34
  /** Audio source URL (`mp3`, `aac`, `wav`, …). */
35
35
  src?: string;
36
- /** MIME type hint — gates playability via the player's `canPlayType` probe. */
36
+ /** MIME type hint — typed onto the inline `<source>` element. */
37
37
  mimeType?: string;
38
38
  /**
39
- * Filename — the download default name for the trailing action, and
40
- * part of each control's accessible name.
39
+ * Filename — used as the download default name (consumed by the
40
+ * native player's kebab menu). The HTML `<audio>` element doesn't
41
+ * expose a built-in title slot, so we don't render the filename
42
+ * inside the bubble itself.
41
43
  */
42
44
  filename?: string;
43
45
  /**
44
46
  * Stacked audio. Takes precedence over `src` when set. Each item
45
- * renders its own waveform row, vertically stacked inside the same
46
- * bubble with an 8px gap. Sent + Received only — the composer surface
47
- * accepts a single attachment at a time.
47
+ * renders its own native `<audio controls>` player, vertically
48
+ * stacked inside the same bubble with an 8px gap between players.
49
+ * Sent + Received only the composer surface accepts a single
50
+ * attachment at a time.
48
51
  */
49
52
  items?: MessageAttachmentAudioItem[];
50
- /** See `AudioItem.durationSeconds`. Applies to the single-`src` shape. */
51
- durationSeconds?: number;
52
- /** See `AudioItem.waveformData`. Applies to the single-`src` shape. */
53
- waveformData?: number[];
53
+ /**
54
+ * `<audio preload>` hint applied to every player on the bubble.
55
+ * When omitted, the default depends on the rendered shape:
56
+ *
57
+ * - Single audio (no `items`, or `items.length === 1`) →
58
+ * `'metadata'` so the native player surfaces duration
59
+ * immediately.
60
+ * - Stacked audio (`items.length > 1`) → `'none'` so a thread
61
+ * of voice memos doesn't fan out N parallel metadata requests
62
+ * on first paint.
63
+ *
64
+ * Per-track overrides live on `AudioItem.preload`.
65
+ */
66
+ preload?: MediaPreloadMode;
54
67
  }
55
68
 
56
69
  /**
@@ -74,6 +87,8 @@ export declare type BroadcastClickHandler = (broadcast: {
74
87
  name?: string;
75
88
  }) => void;
76
89
 
90
+ export declare type BroadcastNameResolver = (broadcastId: string) => string | undefined;
91
+
77
92
  /**
78
93
  * Build the meta line shown under the title in compact document / file
79
94
  * attachments — `EXT · SIZE` (`PDF · 379.5 KB`). Either part is dropped
@@ -197,7 +212,7 @@ export declare const ChannelView: default_2.NamedExoticComponent<ChannelViewProp
197
212
  * Props that MessagingShell passes through to ChannelView.
198
213
  * ChannelViewProps is the source of truth for these props.
199
214
  */
200
- declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customChannelActions' | 'renderChannelActions' | 'onParticipantNameClick' | 'renderMessage' | 'onMessageLinkClick' | 'onBroadcastClick' | 'showChannelInfo' | 'composerInput'>;
215
+ declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customChannelActions' | 'renderChannelActions' | 'onParticipantNameClick' | 'renderMessage' | 'onMessageLinkClick' | 'onBroadcastClick' | 'resolveBroadcastName' | 'showChannelInfo' | 'composerInput'>;
201
216
 
202
217
  /**
203
218
  * ChannelView component props
@@ -388,6 +403,13 @@ export declare interface ChannelViewProps {
388
403
  * Hosts own navigation so this shared package never assumes an admin URL.
389
404
  */
390
405
  onBroadcastClick?: BroadcastClickHandler;
406
+ /**
407
+ * Resolves the current campaign name for a broadcast label. The host must
408
+ * provide a new resolver reference when a returned name changes so
409
+ * ChannelView re-renders and publishes the new value to memoized rows. The
410
+ * stamped message name remains the fallback until this returns a name.
411
+ */
412
+ resolveBroadcastName?: BroadcastNameResolver;
391
413
  /**
392
414
  * Passed to Stream `Channel` as `SendButton`. Required for hosts that replace
393
415
  * the send control: `Channel` merges this into `ComponentContext` and an
@@ -529,7 +551,9 @@ export declare function formatFileSize(bytes: number): string;
529
551
 
530
552
  /**
531
553
  * Format a date - shows time for today, relative time for older messages
532
- * (e.g., "Just now", "2:08 PM" for today, "Yesterday" for yesterday, "2d" for 2 days ago)
554
+ * (e.g., "Just now", "2:08 PM" for today, "Yesterday" for yesterday,
555
+ * "Mon" for 2-6 days ago, "Jul 14" for older dates this year,
556
+ * "Jul 14, 2025" for previous years)
533
557
  */
534
558
  export declare const formatRelativeTime: (date: Date) => string;
535
559
 
@@ -961,7 +985,7 @@ declare type MediaPreloadMode = 'none' | 'metadata' | 'auto';
961
985
  * - Pdf / Audio / File render their compact rows / players in a
962
986
  * vertical stack with an 8px gap between rows. Each row keeps
963
987
  * its own activation target — Pdf opens the carousel-aware
964
- * `PdfViewer` at that index, Audio gets its own waveform player,
988
+ * `PdfViewer` at that index, Audio gets its own native player,
965
989
  * and File downloads the asset for that row.
966
990
  *
967
991
  * The Composer surface accepts only a single attachment at a time,
@@ -979,29 +1003,12 @@ declare type MediaPreloadMode = 'none' | 'metadata' | 'auto';
979
1003
  * download button to the chrome; the Video viewer relies on the
980
1004
  * native `<video controls>` download menu, and the Pdf viewer
981
1005
  * inherits the browser's built-in PDF download. Sibling-row Download
982
- * buttons on Pdf / File and the Audio card's trailing action expose
1006
+ * buttons on Pdf / File and the native `<audio>` controls expose
983
1007
  * downloads outside the viewer too. The lightbox chrome is styled
984
1008
  * with plain CSS (`.mes-media-viewer*`) so the package's shipped
985
1009
  * stylesheet is enough to render the viewer correctly without a
986
1010
  * Tailwind dependency in the consumer.
987
1011
  *
988
- * # Audio requires a `<Channel>` ancestor
989
- *
990
- * `MessageAttachment.Audio.*` renders the waveform player card —
991
- * a 32px play toggle, a 35-bar waveform with a progress fill and a
992
- * native seek control, the duration, and a 32px trailing action
993
- * (Remove in the composer, Download once sent). Playback is
994
- * `stream-chat-react`'s pooled `AudioPlayer`, which is what supplies
995
- * single-player-at-a-time across a thread, pause-on-navigation and
996
- * saved-position restore across virtualized remounts.
997
- *
998
- * That pool is provided by `<Channel>`. Rendered outside one the card
999
- * still draws in full, but its play and seek controls are disabled —
1000
- * so a story or a new consumer surface must mount `<Channel>` (or
1001
- * `stream-chat-react`'s `WithAudioPlayback`) for the card to be
1002
- * playable. The waveform bars themselves are styled with plain CSS
1003
- * (`.mes-audio-wave*`), so the shipped stylesheet is enough.
1004
- *
1005
1012
  * # Lazy-loading defaults
1006
1013
  *
1007
1014
  * Media attachments bake in network-friendly defaults for long chat
@@ -1022,12 +1029,13 @@ declare type MediaPreloadMode = 'none' | 'metadata' | 'auto';
1022
1029
  * `VideoItem.preload` per item). Once the viewer is open the
1023
1030
  * active item bumps to `preload="metadata"` so duration / the
1024
1031
  * first frame appear immediately.
1025
- * - **Audio** — no audio bytes are fetched until the user presses
1026
- * play: the pooled `AudioPlayer` acquires the shared `<audio>`
1027
- * element lazily. A card whose `durationSeconds` is unknown makes
1028
- * one `preload="metadata"` header request to fill the duration
1029
- * slot; supplying `durationSeconds` (which `StreamAttachmentMessage`
1030
- * maps from the attachment's persisted `duration`) avoids even that.
1032
+ * - **Audio** — a single audio bubble defaults to
1033
+ * `preload="metadata"` so the native player surfaces duration
1034
+ * immediately. A stacked audio bubble (`items.length > 1`)
1035
+ * defaults to `preload="none"` for every player so a thread of
1036
+ * voice memos doesn't fan out N parallel metadata requests on
1037
+ * first paint. Override via the attachment's `preload` prop
1038
+ * (or `AudioItem.preload` per track).
1031
1039
  * - **Pdf** / **File** — no inline media element to throttle; the
1032
1040
  * bubble renders compact rows that download / open lazily on
1033
1041
  * activation. No `loading` / `preload` props on these surfaces.
@@ -1072,40 +1080,28 @@ export declare interface MessageAttachmentAudioComposerProps extends ComposerExt
1072
1080
  src?: string;
1073
1081
  mimeType?: string;
1074
1082
  filename?: string;
1075
- /** See `AudioItem.durationSeconds`. */
1076
- durationSeconds?: number;
1077
- /** See `AudioItem.waveformData`. */
1078
- waveformData?: number[];
1083
+ /** See `AudioAttachmentSharedProps.preload`. */
1084
+ preload?: MediaPreloadMode;
1079
1085
  }
1080
1086
 
1081
1087
  /** A single audio file inside an Audio attachment (single or stacked). */
1082
1088
  export declare interface MessageAttachmentAudioItem {
1083
1089
  src: string;
1084
- /** MIME type hint — gates playability via the player's `canPlayType` probe. */
1090
+ /** MIME type hint — typed onto the inline `<source>` element. */
1085
1091
  mimeType?: string;
1086
1092
  /**
1087
- * Filename — the download default name for the card's trailing
1088
- * action, and part of the card's accessible name.
1093
+ * Filename — used as the download default name (consumed by the
1094
+ * native player's kebab menu).
1089
1095
  */
1090
1096
  filename?: string;
1091
1097
  /**
1092
- * Track length in seconds. Rendered as `MM:SS` before playback
1093
- * starts, so the duration is correct on first paint with no network
1094
- * request. Sourced from the Stream attachment's `duration` field,
1095
- * which the composer writes at send time. When omitted the card
1096
- * shows `--:--` until a one-shot `loadedmetadata` probe resolves.
1098
+ * Per-track override for the `<audio preload>` hint. Defaults to
1099
+ * the parent attachment's `preload` value, which itself defaults
1100
+ * to `'metadata'` for a single audio (so the native player can
1101
+ * surface duration immediately) and `'none'` for a stacked thread
1102
+ * (so a fan-out of metadata requests doesn't happen on first paint).
1097
1103
  */
1098
- durationSeconds?: number;
1099
- /**
1100
- * Waveform amplitudes, each `0..1`, in track order. Resampled to the
1101
- * rendered bar count, so any length is accepted. Sourced from the
1102
- * Stream attachment's `waveform_data`, which the composer computes
1103
- * from the local file and writes at send time.
1104
- *
1105
- * When omitted, the card renders a flat placeholder row: every bar at
1106
- * the minimum height, with the progress fill still tracking playback.
1107
- */
1108
- waveformData?: number[];
1104
+ preload?: MediaPreloadMode;
1109
1105
  }
1110
1106
 
1111
1107
  export declare type MessageAttachmentAudioReceivedProps = AudioAttachmentSharedProps;
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-B96WLmx6.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.31.0-rc-1785485102",
3
+ "version": "3.31.0",
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.3.0-rc-1785485102",
53
+ "@linktr.ee/messaging-core": "^2.2.0",
54
54
  "@linktr.ee/messaging-taxonomy": "^0.5.0",
55
55
  "@phosphor-icons/react": "^2.1.10",
56
56
  "culori": "^4.0.2"
@@ -0,0 +1,116 @@
1
+ import { act, render, screen } from '@testing-library/react'
2
+ import React, { useEffect } from 'react'
3
+ import { Channel as StreamChannel, StreamChat } from 'stream-chat'
4
+ import { Channel, Chat } from 'stream-chat-react'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+
7
+ import { ChannelView } from './ChannelView'
8
+
9
+ const message = {
10
+ id: 'message-1',
11
+ cid: 'messaging:channel-1',
12
+ type: 'regular',
13
+ text: 'July class details',
14
+ status: 'received',
15
+ user: { id: 'linker-1', name: 'Linker' },
16
+ metadata: {
17
+ broadcast_id: 'broadcast-1',
18
+ broadcast_name: 'July 9th Class attendees',
19
+ },
20
+ attachments: [],
21
+ latest_reactions: [],
22
+ own_reactions: [],
23
+ reaction_counts: {},
24
+ reaction_scores: {},
25
+ reply_count: 0,
26
+ mentioned_users: [],
27
+ created_at: new Date(),
28
+ updated_at: new Date(),
29
+ }
30
+
31
+ const createChannel = () => {
32
+ const client = new StreamChat('mock-api-key', {
33
+ allowServerSideConnect: true,
34
+ })
35
+ client.userID = 'linker-1'
36
+ client.user = { id: 'linker-1', name: 'Linker' }
37
+
38
+ const channel = client.channel('messaging', 'channel-1')
39
+ channel.initialized = true
40
+ channel.state.messages = [message] as never
41
+ channel.state.members = {
42
+ 'linker-1': {
43
+ user: { id: 'linker-1', name: 'Linker' },
44
+ user_id: 'linker-1',
45
+ role: 'owner',
46
+ },
47
+ 'visitor-1': {
48
+ user: { id: 'visitor-1', name: 'Visitor' },
49
+ user_id: 'visitor-1',
50
+ role: 'member',
51
+ },
52
+ } as never
53
+
54
+ return { channel, client }
55
+ }
56
+
57
+ describe('ChannelView broadcast-name resolution', () => {
58
+ it('updates an existing Stream message row when the resolved name arrives', async () => {
59
+ const { channel, client } = createChannel()
60
+ const firstResolver = () => undefined
61
+ const secondResolver = () => 'July 9th Class absentee'
62
+ const mounts = vi.fn()
63
+ const unmounts = vi.fn()
64
+
65
+ const RowProbe = ({ children }: { children: React.ReactNode }) => {
66
+ useEffect(() => {
67
+ mounts()
68
+ return unmounts
69
+ }, [])
70
+ return <>{children}</>
71
+ }
72
+ const renderMessage = (messageNode: React.ReactElement) => (
73
+ <RowProbe>{messageNode}</RowProbe>
74
+ )
75
+
76
+ let rerender!: ReturnType<typeof render>['rerender']
77
+ await act(async () => {
78
+ rerender = render(
79
+ <Chat client={client}>
80
+ <Channel channel={channel as StreamChannel}>
81
+ <ChannelView
82
+ channel={channel as StreamChannel}
83
+ resolveBroadcastName={firstResolver}
84
+ renderMessage={renderMessage}
85
+ />
86
+ </Channel>
87
+ </Chat>
88
+ ).rerender
89
+ })
90
+
91
+ expect(
92
+ screen.getByTestId('sent-message-broadcast-status')
93
+ ).toHaveTextContent('July 9th Class attendees')
94
+ expect(mounts).toHaveBeenCalledOnce()
95
+
96
+ act(() => {
97
+ rerender(
98
+ <Chat client={client}>
99
+ <Channel channel={channel as StreamChannel}>
100
+ <ChannelView
101
+ channel={channel as StreamChannel}
102
+ resolveBroadcastName={secondResolver}
103
+ renderMessage={renderMessage}
104
+ />
105
+ </Channel>
106
+ </Chat>
107
+ )
108
+ })
109
+
110
+ expect(
111
+ screen.getByTestId('sent-message-broadcast-status')
112
+ ).toHaveTextContent('July 9th Class absentee')
113
+ expect(mounts).toHaveBeenCalledOnce()
114
+ expect(unmounts).not.toHaveBeenCalled()
115
+ })
116
+ })
@@ -16,6 +16,10 @@ import type { ChannelViewProps } from '../types'
16
16
  import { ChannelHeaderRedesign } from './ChannelHeaderRedesign'
17
17
  import { CustomDateSeparator } from './CustomDateSeparator'
18
18
  import { CustomMessage } from './CustomMessage'
19
+ import {
20
+ BroadcastNameContext,
21
+ useBroadcastNamesById,
22
+ } from './CustomMessage/BroadcastNameContext'
19
23
  import { CustomMessageActions } from './CustomMessage/CustomMessageActions'
20
24
  import { CustomMessageInput } from './CustomMessageInput'
21
25
  import { CustomSystemMessage } from './CustomSystemMessage'
@@ -57,6 +61,7 @@ const ChannelViewInner: React.FC<{
57
61
  showChannelInfo?: boolean
58
62
  onParticipantNameClick?: () => void
59
63
  onBroadcastClick?: ChannelViewProps['onBroadcastClick']
64
+ resolveBroadcastName?: ChannelViewProps['resolveBroadcastName']
60
65
  composerInput?: ChannelViewProps['composerInput']
61
66
  }> = ({
62
67
  onBack,
@@ -84,9 +89,10 @@ const ChannelViewInner: React.FC<{
84
89
  showChannelInfo = true,
85
90
  onParticipantNameClick,
86
91
  onBroadcastClick,
92
+ resolveBroadcastName,
87
93
  composerInput,
88
94
  }) => {
89
- const { channel } = useChannelStateContext()
95
+ const { channel, messages } = useChannelStateContext()
90
96
  const onBroadcastClickRef = useRef(onBroadcastClick)
91
97
  useEffect(() => {
92
98
  onBroadcastClickRef.current = onBroadcastClick
@@ -99,6 +105,12 @@ const ChannelViewInner: React.FC<{
99
105
  const messageBroadcastClick = onBroadcastClick
100
106
  ? stableOnBroadcastClick
101
107
  : undefined
108
+ // Resolve outside memoized Stream rows so new campaign names publish without
109
+ // remounting the rows.
110
+ const broadcastNamesById = useBroadcastNamesById(
111
+ messages,
112
+ resolveBroadcastName
113
+ )
102
114
 
103
115
  const hasVisibleMessages = channel.state.messages.some(
104
116
  (message) => message.type !== 'deleted'
@@ -153,7 +165,7 @@ const ChannelViewInner: React.FC<{
153
165
  )
154
166
 
155
167
  return (
156
- <>
168
+ <BroadcastNameContext.Provider value={broadcastNamesById}>
157
169
  <WithComponents
158
170
  overrides={{
159
171
  Message: MessageOverride,
@@ -265,7 +277,7 @@ const ChannelViewInner: React.FC<{
265
277
  )}
266
278
  </Window>
267
279
  </WithComponents>
268
- </>
280
+ </BroadcastNameContext.Provider>
269
281
  )
270
282
  }
271
283
 
@@ -308,6 +320,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
308
320
  showChannelInfo = true,
309
321
  onParticipantNameClick,
310
322
  onBroadcastClick,
323
+ resolveBroadcastName,
311
324
  composerInput,
312
325
  }) => {
313
326
  // Custom send message handler that:
@@ -422,6 +435,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
422
435
  showChannelInfo={showChannelInfo}
423
436
  onParticipantNameClick={onParticipantNameClick}
424
437
  onBroadcastClick={onBroadcastClick}
438
+ resolveBroadcastName={resolveBroadcastName}
425
439
  composerInput={composerInput}
426
440
  />
427
441
  </Channel>
@@ -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>
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { buildBroadcastNamesById } from './BroadcastNameContext'
4
+
5
+ describe('buildBroadcastNamesById', () => {
6
+ it('returns undefined when no resolver is provided', () => {
7
+ expect(
8
+ buildBroadcastNamesById(
9
+ [{ metadata: { broadcast_id: 'broadcast-1' } }],
10
+ undefined
11
+ )
12
+ ).toBeUndefined()
13
+ })
14
+
15
+ it('resolves each unique broadcast id once', () => {
16
+ const resolveBroadcastName = vi.fn((id: string) => `name-for-${id}`)
17
+
18
+ expect(
19
+ buildBroadcastNamesById(
20
+ [
21
+ { metadata: { broadcast_id: 'broadcast-1' } },
22
+ { metadata: { broadcast_id: 'broadcast-1' } },
23
+ { metadata: { broadcast_id: 'broadcast-2' } },
24
+ { metadata: {} },
25
+ ],
26
+ resolveBroadcastName
27
+ )
28
+ ).toEqual({
29
+ 'broadcast-1': 'name-for-broadcast-1',
30
+ 'broadcast-2': 'name-for-broadcast-2',
31
+ })
32
+ expect(resolveBroadcastName).toHaveBeenCalledTimes(2)
33
+ })
34
+
35
+ it('omits ids the resolver cannot resolve yet', () => {
36
+ expect(
37
+ buildBroadcastNamesById(
38
+ [{ metadata: { broadcast_id: 'broadcast-1' } }],
39
+ () => undefined
40
+ )
41
+ ).toBeUndefined()
42
+ })
43
+ })