@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.
- package/dist/{Card-DbdWDBMe.cjs → Card-Bh-xdrvU.cjs} +2 -2
- package/dist/{Card-DbdWDBMe.cjs.map → Card-Bh-xdrvU.cjs.map} +1 -1
- package/dist/{Card-0TLA8XHU.js → Card-Bm_eCczn.js} +3 -3
- package/dist/{Card-0TLA8XHU.js.map → Card-Bm_eCczn.js.map} +1 -1
- package/dist/{Card-B-D_LbnV.cjs → Card-C33YVsqY.cjs} +2 -2
- package/dist/{Card-B-D_LbnV.cjs.map → Card-C33YVsqY.cjs.map} +1 -1
- package/dist/{Card-BaaerKBC.js → Card-C8Q8MH6Y.js} +2 -2
- package/dist/{Card-BaaerKBC.js.map → Card-C8Q8MH6Y.js.map} +1 -1
- package/dist/{Card-DZVa2CeI.js → Card-D8QPP3I9.js} +2 -2
- package/dist/{Card-DZVa2CeI.js.map → Card-D8QPP3I9.js.map} +1 -1
- package/dist/{Card-Bfxdewx_.cjs → Card-DO7ipVZF.cjs} +2 -2
- package/dist/{Card-Bfxdewx_.cjs.map → Card-DO7ipVZF.cjs.map} +1 -1
- package/dist/{LockedThumbnail-B4gDHeh7.js → LockedThumbnail-6Ykc8JiU.js} +2 -2
- package/dist/{LockedThumbnail-B4gDHeh7.js.map → LockedThumbnail-6Ykc8JiU.js.map} +1 -1
- package/dist/{LockedThumbnail-DkwFwgpU.cjs → LockedThumbnail-yEutwXEz.cjs} +2 -2
- package/dist/{LockedThumbnail-DkwFwgpU.cjs.map → LockedThumbnail-yEutwXEz.cjs.map} +1 -1
- package/dist/{index-BmCc1-F3.js → index-Cj6b1oEe.js} +797 -790
- package/dist/index-Cj6b1oEe.js.map +1 -0
- package/dist/index-DluSX5DB.cjs +2 -0
- package/dist/index-DluSX5DB.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/ChannelView.stories.tsx +5 -6
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +21 -23
- package/src/components/CustomMessageInput/index.tsx +42 -24
- package/src/components/MessagingShell/MessagingShell.test.tsx +93 -0
- package/src/components/MessagingShell/index.tsx +46 -4
- package/src/types.ts +9 -6
- package/dist/index-BmCc1-F3.js.map +0 -1
- package/dist/index-Cg-bxSZn.cjs +0 -2
- 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-
|
|
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
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
211
|
-
*
|
|
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-
|
|
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
|
@@ -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-
|
|
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
|
|
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-
|
|
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-
|
|
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('
|
|
181
|
+
it('replaces the composer with the locked panel when disabled (channel not frozen)', () => {
|
|
182
182
|
mockChannelData = {}
|
|
183
183
|
|
|
184
|
-
const { container } = renderWithProviders(
|
|
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="
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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
|
|
33
|
-
disabled =
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
|
91
|
-
*
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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={
|
|
116
|
-
aria-disabled={
|
|
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
|
-
<
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
|
165
|
-
*
|
|
167
|
+
* Explanatory text shown inside the locked panel. Only rendered when
|
|
168
|
+
* `composerDisabled` is true.
|
|
166
169
|
*/
|
|
167
170
|
composerDisabledReason?: string
|
|
168
171
|
|