@linktr.ee/messaging-react 3.1.0-rc-1780514752 → 3.1.1

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 (33) hide show
  1. package/dist/{Card-DbdWDBMe.cjs → Card-Bh-xdrvU.cjs} +2 -2
  2. package/dist/{Card-DbdWDBMe.cjs.map → Card-Bh-xdrvU.cjs.map} +1 -1
  3. package/dist/{Card-0TLA8XHU.js → Card-Bm_eCczn.js} +3 -3
  4. package/dist/{Card-0TLA8XHU.js.map → Card-Bm_eCczn.js.map} +1 -1
  5. package/dist/{Card-B-D_LbnV.cjs → Card-C33YVsqY.cjs} +2 -2
  6. package/dist/{Card-B-D_LbnV.cjs.map → Card-C33YVsqY.cjs.map} +1 -1
  7. package/dist/{Card-BaaerKBC.js → Card-C8Q8MH6Y.js} +2 -2
  8. package/dist/{Card-BaaerKBC.js.map → Card-C8Q8MH6Y.js.map} +1 -1
  9. package/dist/{Card-DZVa2CeI.js → Card-D8QPP3I9.js} +2 -2
  10. package/dist/{Card-DZVa2CeI.js.map → Card-D8QPP3I9.js.map} +1 -1
  11. package/dist/{Card-Bfxdewx_.cjs → Card-DO7ipVZF.cjs} +2 -2
  12. package/dist/{Card-Bfxdewx_.cjs.map → Card-DO7ipVZF.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-B4gDHeh7.js → LockedThumbnail-6Ykc8JiU.js} +2 -2
  14. package/dist/{LockedThumbnail-B4gDHeh7.js.map → LockedThumbnail-6Ykc8JiU.js.map} +1 -1
  15. package/dist/{LockedThumbnail-DkwFwgpU.cjs → LockedThumbnail-yEutwXEz.cjs} +2 -2
  16. package/dist/{LockedThumbnail-DkwFwgpU.cjs.map → LockedThumbnail-yEutwXEz.cjs.map} +1 -1
  17. package/dist/{index-BmCc1-F3.js → index-Cj6b1oEe.js} +797 -790
  18. package/dist/index-Cj6b1oEe.js.map +1 -0
  19. package/dist/index-DluSX5DB.cjs +2 -0
  20. package/dist/index-DluSX5DB.cjs.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +9 -6
  23. package/dist/index.js +1 -1
  24. package/package.json +1 -1
  25. package/src/components/ChannelView.stories.tsx +5 -6
  26. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +21 -23
  27. package/src/components/CustomMessageInput/index.tsx +42 -24
  28. package/src/components/MessagingShell/MessagingShell.test.tsx +93 -0
  29. package/src/components/MessagingShell/index.tsx +46 -4
  30. package/src/types.ts +9 -6
  31. package/dist/index-BmCc1-F3.js.map +0 -1
  32. package/dist/index-Cg-bxSZn.cjs +0 -2
  33. package/dist/index-Cg-bxSZn.cjs.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-Cg-bxSZn.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.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.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;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-DluSX5DB.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.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.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -200,15 +200,18 @@ export declare interface ChannelViewProps {
200
200
  */
201
201
  showFollowerStatus?: boolean;
202
202
  /**
203
- * Lock the message composer (read-only, send disabled). Defaults to false.
204
- * Combined with the channel's `frozen` flag either one locks the input.
205
- * Used by the Linktree official channel, where the composer stays locked
206
- * until the linker messages Linktree from its public profile.
203
+ * Replace the message composer with a non-interactive locked panel showing
204
+ * `composerDisabledReason`. Defaults to false. Used by the Linktree official
205
+ * channel, where the linker cannot message Linktree from the inbox (they
206
+ * message Linktree from its public profile instead).
207
+ *
208
+ * Distinct from the channel's `frozen` flag, which keeps the composer
209
+ * rendered but read-only/dimmed.
207
210
  */
208
211
  composerDisabled?: boolean;
209
212
  /**
210
- * Explanatory text rendered below the composer while it is locked.
211
- * Only shown when `composerDisabled` is true (or the channel is frozen).
213
+ * Explanatory text shown inside the locked panel. Only rendered when
214
+ * `composerDisabled` is true.
212
215
  */
213
216
  composerDisabledReason?: string;
214
217
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-BmCc1-F3.js";
1
+ import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-Cj6b1oEe.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.1.0-rc-1780514752",
3
+ "version": "3.1.1",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -225,7 +225,7 @@ const Template: StoryFn<TemplateProps> = (args) => {
225
225
 
226
226
  return (
227
227
  <Chat client={client}>
228
- <div className="h-screen w-full bg-white">
228
+ <div className="h-screen w-full bg-[#FBFAF9]">
229
229
  <ChannelView {...channelViewProps} channel={channel} />
230
230
  </div>
231
231
  </Chat>
@@ -279,8 +279,7 @@ RestrictedOfficialChannel.args = {
279
279
  showReportParticipant: false,
280
280
  showFollowerStatus: false,
281
281
  composerDisabled: true,
282
- composerDisabledReason:
283
- 'Message Linktree from your profile to start the conversation',
282
+ composerDisabledReason: 'Only Linktree can send messages on this thread',
284
283
  followerStatus: true, // would normally render "Subscribed to you" — suppressed here
285
284
  onLeaveConversation: (channel) =>
286
285
  console.log('Leave conversation:', channel.id),
@@ -289,7 +288,7 @@ RestrictedOfficialChannel.parameters = {
289
288
  docs: {
290
289
  description: {
291
290
  story:
292
- 'Restricted action surface used by the Linktree official channel: block, report, and the subscription-status label are hidden, and the composer is locked with an explanatory reason. Delete conversation, favorite, and chat info remain available. Open the chat info dialog (3-dot / name click) to see block & report removed.',
291
+ 'Restricted action surface used by the Linktree official channel: block, report, and the subscription-status label are hidden, and the composer is replaced by a locked panel explaining the linker cannot send messages on this thread. Delete conversation, favorite, and chat info remain available. Open the chat info dialog (3-dot / name click) to see block & report removed.',
293
292
  },
294
293
  },
295
294
  }
@@ -463,7 +462,7 @@ const WithStarButtonTemplate: StoryFn<ComponentProps> = (args) => {
463
462
 
464
463
  return (
465
464
  <Chat client={client}>
466
- <div className="h-screen w-full bg-white">
465
+ <div className="h-screen w-full bg-[#FBFAF9]">
467
466
  <ChannelView {...args} channel={channel} />
468
467
  </div>
469
468
  </Chat>
@@ -520,7 +519,7 @@ const EmptyTemplate: StoryFn<ComponentProps> = (args) => {
520
519
 
521
520
  return (
522
521
  <Chat client={client}>
523
- <div className="h-screen w-full bg-white">
522
+ <div className="h-screen w-full bg-[#FBFAF9]">
524
523
  <ChannelView {...args} channel={channel} />
525
524
  </div>
526
525
  </Chat>
@@ -178,45 +178,43 @@ describe('CustomMessageInput', () => {
178
178
  expect(sendButton).toBeDisabled()
179
179
  })
180
180
 
181
- it('locks the composer when the disabled prop is true (channel not frozen)', () => {
181
+ it('replaces the composer with the locked panel when disabled (channel not frozen)', () => {
182
182
  mockChannelData = {}
183
183
 
184
- const { container } = renderWithProviders(<CustomMessageInput disabled />)
185
-
186
- const messageInput = container.querySelector('.message-input')
187
- expect(messageInput).toHaveAttribute('aria-disabled', 'true')
188
- expect(messageInput).toHaveAttribute('inert')
189
-
190
- const textarea = screen.getByTestId('textarea-composer')
191
- expect(textarea).toHaveAttribute('readonly')
192
- expect(textarea).toHaveAttribute('tabindex', '-1')
193
-
194
- const sendButton = screen.getByRole('button', { name: /send/i })
195
- expect(sendButton).toBeDisabled()
196
- })
197
-
198
- it('renders the disabled reason when the composer is disabled with a reason', () => {
199
- mockChannelData = {}
200
-
201
- renderWithProviders(
184
+ const { container } = renderWithProviders(
202
185
  <CustomMessageInput
203
186
  disabled
204
- disabledReason="Message Linktree from your profile to reply"
187
+ disabledReason="Only Linktree can send messages on this thread"
205
188
  />
206
189
  )
207
190
 
191
+ // The interactive input is gone entirely — no textarea, no send button.
192
+ expect(
193
+ screen.queryByTestId('stream-message-input')
194
+ ).not.toBeInTheDocument()
195
+ expect(screen.queryByTestId('textarea-composer')).not.toBeInTheDocument()
196
+ expect(container.querySelector('.message-input')).not.toBeInTheDocument()
197
+
198
+ // The locked panel with the reason replaces it.
208
199
  expect(
209
- screen.getByText(/Message Linktree from your profile to reply/i)
200
+ container.querySelector('.messaging-composer-locked-panel')
201
+ ).toBeInTheDocument()
202
+ expect(
203
+ screen.getByText(/Only Linktree can send messages on this thread/i)
210
204
  ).toBeInTheDocument()
211
205
  })
212
206
 
213
- it('does not render the disabled reason when the composer is not disabled', () => {
207
+ it('does not render the locked panel when the composer is not disabled', () => {
214
208
  mockChannelData = {}
215
209
 
216
- renderWithProviders(
210
+ const { container } = renderWithProviders(
217
211
  <CustomMessageInput disabledReason="should not show" />
218
212
  )
219
213
 
214
+ expect(
215
+ container.querySelector('.messaging-composer-locked-panel')
216
+ ).not.toBeInTheDocument()
220
217
  expect(screen.queryByText(/should not show/i)).not.toBeInTheDocument()
218
+ expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
221
219
  })
222
220
  })
@@ -1,5 +1,5 @@
1
1
  import { ArrowUpIcon } from '@phosphor-icons/react'
2
- import React, { useCallback } from 'react'
2
+ import React, { useContext } from 'react'
3
3
  import {
4
4
  AttachmentPreviewList as DefaultAttachmentPreviewList,
5
5
  MessageInput,
@@ -13,6 +13,16 @@ import {
13
13
 
14
14
  import { CustomLinkPreviewList } from '../CustomLinkPreviewList'
15
15
 
16
+ /**
17
+ * Carries the channel's `frozen` state down to CustomMessageInputInner, which
18
+ * renders the composer as a read-only/disabled input. Using a context — rather
19
+ * than closing the value into the `Input` component passed to Stream's
20
+ * <MessageInput> — keeps CustomMessageInputInner a stable reference, so the
21
+ * value toggling re-renders the composer instead of unmounting and remounting
22
+ * it (which would drop textarea focus and interrupt IME composition).
23
+ */
24
+ const ComposerLockedContext = React.createContext(false)
25
+
16
26
  const DefaultSendButton: React.FC<{
17
27
  sendMessage: () => void
18
28
  disabled?: boolean
@@ -29,9 +39,8 @@ const DefaultSendButton: React.FC<{
29
39
  </button>
30
40
  )
31
41
 
32
- const CustomMessageInputInner: React.FC<{ disabled?: boolean }> = ({
33
- disabled = false,
34
- }) => {
42
+ const CustomMessageInputInner: React.FC = () => {
43
+ const disabled = useContext(ComposerLockedContext)
35
44
  const { handleSubmit } = useMessageInputContext()
36
45
 
37
46
  const hasSendableData = useMessageComposerHasSendableData()
@@ -79,16 +88,17 @@ export interface CustomMessageInputProps {
79
88
  renderActions?: () => React.ReactNode
80
89
  renderFooter?: () => React.ReactNode
81
90
  /**
82
- * Lock the composer (read-only textarea, disabled send, no autofocus).
83
- * Combined with the channel's `frozen` flag either one locks the input.
84
- * Used by the Linktree official channel, where the composer stays locked
85
- * until the linker messages Linktree from its public profile.
86
- * Defaults to false.
91
+ * Replace the composer entirely with a non-interactive locked panel that
92
+ * shows `disabledReason`. Used by the Linktree official channel, where the
93
+ * linker cannot message Linktree from the inbox. Defaults to false.
94
+ *
95
+ * Distinct from the channel's `frozen` flag, which keeps the composer
96
+ * rendered but read-only/dimmed.
87
97
  */
88
98
  disabled?: boolean
89
99
  /**
90
- * Explanatory text rendered below the composer while it is locked.
91
- * Only shown when the composer is disabled (via `disabled` or `frozen`).
100
+ * Explanatory text shown inside the locked panel. Only rendered when
101
+ * `disabled` is true.
92
102
  */
93
103
  disabledReason?: string
94
104
  }
@@ -101,19 +111,30 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
101
111
  }) => {
102
112
  const { channel } = useChannelStateContext()
103
113
  const isFrozen = channel?.data?.frozen === true
104
- const isLocked = isFrozen || disabled
105
114
 
106
- const Input = useCallback(
107
- () => <CustomMessageInputInner disabled={isLocked} />,
108
- [isLocked]
109
- )
115
+ // Linktree official channel: the composer is replaced by an info panel
116
+ // explaining the linker cannot send messages on this thread.
117
+ if (disabled) {
118
+ return (
119
+ <>
120
+ <div className="messaging-composer-locked-panel flex w-full flex-col items-center justify-center gap-3 px-6 py-4">
121
+ {disabledReason ? (
122
+ <p className="max-w-[345px] text-center text-xs font-normal leading-[1.3] tracking-[0.12px] text-black/40">
123
+ {disabledReason}
124
+ </p>
125
+ ) : null}
126
+ </div>
127
+ {renderFooter?.()}
128
+ </>
129
+ )
130
+ }
110
131
 
111
132
  return (
112
133
  <div className="flex flex-col gap-4 p-4">
113
134
  <div
114
135
  // @ts-expect-error Only React 19 onwards has `inert` in its types.
115
- inert={isLocked ? '' : undefined}
116
- aria-disabled={isLocked || undefined}
136
+ inert={isFrozen ? '' : undefined}
137
+ aria-disabled={isFrozen || undefined}
117
138
  className="message-input flex items-end gap-4 aria-disabled:opacity-40"
118
139
  >
119
140
  {renderActions && (
@@ -121,13 +142,10 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
121
142
  {renderActions()}
122
143
  </div>
123
144
  )}
124
- <MessageInput Input={Input} />
145
+ <ComposerLockedContext.Provider value={isFrozen}>
146
+ <MessageInput Input={CustomMessageInputInner} />
147
+ </ComposerLockedContext.Provider>
125
148
  </div>
126
- {isLocked && disabledReason ? (
127
- <p className="message-input-disabled-reason px-2 text-center text-sm text-black/55">
128
- {disabledReason}
129
- </p>
130
- ) : null}
131
149
  {renderFooter?.()}
132
150
  </div>
133
151
  )
@@ -168,4 +168,97 @@ describe('MessagingShell', () => {
168
168
  'Conversation ended'
169
169
  )
170
170
  })
171
+
172
+ it('does not create the channel again when the load effect re-runs for the same pair (e.g. service identity settles during connect)', async () => {
173
+ queryChannelsMock.mockResolvedValue([])
174
+ startChannelWithParticipantMock.mockResolvedValue(makeChannel('created-1'))
175
+
176
+ const { rerender } = renderWithProviders(
177
+ <MessagingShell
178
+ initialParticipantFilter="other-1"
179
+ initialParticipantData={{ id: 'other-1', name: 'Other Person' }}
180
+ />
181
+ )
182
+
183
+ await waitFor(() =>
184
+ expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1)
185
+ )
186
+
187
+ // A fresh `service` reference (as happens while the connection settles)
188
+ // re-runs the load effect. The once-per-pair guard must prevent a second
189
+ // create for the same viewer/participant pair before the new channel is
190
+ // indexed by queryChannels.
191
+ useMessagingReturn = {
192
+ ...useMessagingReturn,
193
+ service: { startChannelWithParticipant: startChannelWithParticipantMock },
194
+ }
195
+ rerender(
196
+ <MessagingShell
197
+ initialParticipantFilter="other-1"
198
+ initialParticipantData={{ id: 'other-1', name: 'Other Person' }}
199
+ />
200
+ )
201
+
202
+ await new Promise((resolve) => setTimeout(resolve, 50))
203
+ expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1)
204
+ })
205
+
206
+ it('does not wipe a newer pair load guard when an earlier in-flight load fails', async () => {
207
+ const PAIR_A = 'pair-A'
208
+ const PAIR_B = 'pair-B'
209
+
210
+ // Pair A's query stays pending until we reject it, simulating a load still
211
+ // in flight when the participant changes. Pair B resolves with no channel.
212
+ let rejectA: (reason?: unknown) => void = () => {}
213
+ queryChannelsMock.mockImplementation(
214
+ (query: { members?: { $eq?: string[] } }) => {
215
+ if (query?.members?.$eq?.includes(PAIR_A)) {
216
+ return new Promise((_resolve, reject) => {
217
+ rejectA = reject
218
+ })
219
+ }
220
+ return Promise.resolve([])
221
+ }
222
+ )
223
+ startChannelWithParticipantMock.mockResolvedValue(makeChannel('created-b'))
224
+
225
+ const { rerender } = renderWithProviders(
226
+ <MessagingShell
227
+ initialParticipantFilter={PAIR_A}
228
+ initialParticipantData={{ id: PAIR_A, name: 'A' }}
229
+ />
230
+ )
231
+
232
+ // Switch to pair B while A's load is still pending; B creates its channel.
233
+ rerender(
234
+ <MessagingShell
235
+ initialParticipantFilter={PAIR_B}
236
+ initialParticipantData={{ id: PAIR_B, name: 'B' }}
237
+ />
238
+ )
239
+
240
+ await waitFor(() =>
241
+ expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1)
242
+ )
243
+
244
+ // A's stale load now fails. Its catch must not clear pair B's guard.
245
+ rejectA(new Error('transient'))
246
+ await new Promise((resolve) => setTimeout(resolve, 0))
247
+
248
+ // Re-run the load effect for pair B (a fresh `service` reference). With B's
249
+ // guard intact this must not create a second channel.
250
+ useMessagingReturn = {
251
+ ...useMessagingReturn,
252
+ service: { startChannelWithParticipant: startChannelWithParticipantMock },
253
+ }
254
+ rerender(
255
+ <MessagingShell
256
+ initialParticipantFilter={PAIR_B}
257
+ initialParticipantData={{ id: PAIR_B, name: 'B' }}
258
+ />
259
+ )
260
+
261
+ await new Promise((resolve) => setTimeout(resolve, 50))
262
+ expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1)
263
+ })
171
264
  })
@@ -65,13 +65,44 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
65
65
  const onChannelSelectRef = useRef(onChannelSelect)
66
66
  onChannelSelectRef.current = onChannelSelect
67
67
 
68
+ // Track the direct-conversation load to prevent repeated/concurrent loads.
69
+ // Identity-stable deps (above) stop the common re-fire, but legitimate dep
70
+ // changes (e.g. `service`/`client` settling during connect) can still re-run
71
+ // this effect before the just-created channel is indexed, each firing another
72
+ // startChannelWithParticipant call and creating duplicate welcome messages.
73
+ const directConversationLoadRef = useRef<string | null>(null)
74
+
75
+ // Mirror the currently-selected channel into a ref so the async load can read
76
+ // the latest value rather than the (possibly stale) effect-closure value.
77
+ const selectedChannelRef = useRef<Channel | null>(null)
78
+ useEffect(() => {
79
+ selectedChannelRef.current = selectedChannel
80
+ }, [selectedChannel])
81
+
68
82
  useEffect(() => {
69
83
  if (!client || !isConnected) return
70
84
 
71
- const loadInitialChannel = async () => {
72
- const userId = client.userID
73
- if (!userId) return
85
+ const userId = client.userID
86
+ if (!userId) return
87
+
88
+ // Only load once per viewer ↔ participant pair. Set synchronously (before
89
+ // the async work) so re-runs triggered by changing dependency identities
90
+ // bail out instead of issuing another channel-create request.
91
+ const loadKey = `${userId}::${initialParticipantFilter}`
92
+ if (directConversationLoadRef.current === loadKey) return
93
+ directConversationLoadRef.current = loadKey
74
94
 
95
+ // Release the guard only if it still belongs to this load. A newer load for
96
+ // a different pair may have taken ownership while this one was in flight, so
97
+ // clearing unconditionally could wipe the newer guard and let a duplicate
98
+ // load/create slip through.
99
+ const releaseLoadGuard = () => {
100
+ if (directConversationLoadRef.current === loadKey) {
101
+ directConversationLoadRef.current = null
102
+ }
103
+ }
104
+
105
+ const loadInitialChannel = async () => {
75
106
  try {
76
107
  if (debug) {
77
108
  console.log(
@@ -105,6 +136,8 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
105
136
 
106
137
  const participantData = initialParticipantDataRef.current
107
138
  if (!participantData || !service) {
139
+ // Allow a retry once participant data / service become available.
140
+ releaseLoadGuard()
108
141
  setDirectConversationError('No conversation found with this account')
109
142
  if (debug) {
110
143
  console.log(
@@ -136,6 +169,8 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
136
169
  '[MessagingShell] Failed to create conversation:',
137
170
  createErr
138
171
  )
172
+ // Allow a retry for this pair after a transient failure.
173
+ releaseLoadGuard()
139
174
  setDirectConversationError('Failed to create conversation')
140
175
  }
141
176
  } catch (err) {
@@ -143,7 +178,14 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
143
178
  '[MessagingShell] Failed to load initial conversation:',
144
179
  err
145
180
  )
146
- setDirectConversationError('Failed to load conversation')
181
+ // Allow a retry for this pair after a transient failure.
182
+ releaseLoadGuard()
183
+ // Don't replace an already-loaded conversation with an error screen.
184
+ // Read the latest selected channel via ref to avoid acting on a stale
185
+ // closure value when the channel was selected mid-flight.
186
+ if (!selectedChannelRef.current) {
187
+ setDirectConversationError('Failed to load conversation')
188
+ }
147
189
  }
148
190
  }
149
191
 
package/src/types.ts CHANGED
@@ -153,16 +153,19 @@ export interface ChannelViewProps {
153
153
  showFollowerStatus?: boolean
154
154
 
155
155
  /**
156
- * Lock the message composer (read-only, send disabled). Defaults to false.
157
- * Combined with the channel's `frozen` flag either one locks the input.
158
- * Used by the Linktree official channel, where the composer stays locked
159
- * until the linker messages Linktree from its public profile.
156
+ * Replace the message composer with a non-interactive locked panel showing
157
+ * `composerDisabledReason`. Defaults to false. Used by the Linktree official
158
+ * channel, where the linker cannot message Linktree from the inbox (they
159
+ * message Linktree from its public profile instead).
160
+ *
161
+ * Distinct from the channel's `frozen` flag, which keeps the composer
162
+ * rendered but read-only/dimmed.
160
163
  */
161
164
  composerDisabled?: boolean
162
165
 
163
166
  /**
164
- * Explanatory text rendered below the composer while it is locked.
165
- * Only shown when `composerDisabled` is true (or the channel is frozen).
167
+ * Explanatory text shown inside the locked panel. Only rendered when
168
+ * `composerDisabled` is true.
166
169
  */
167
170
  composerDisabledReason?: string
168
171