@linktr.ee/messaging-react 3.1.0 → 3.1.2-rc-1780563075

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 (36) hide show
  1. package/dist/{Card-Cq-cN9n1.cjs → Card-0J3_7gmN.cjs} +2 -2
  2. package/dist/{Card-Cq-cN9n1.cjs.map → Card-0J3_7gmN.cjs.map} +1 -1
  3. package/dist/{Card-BhO5jeP9.js → Card-CserQom-.js} +2 -2
  4. package/dist/{Card-BhO5jeP9.js.map → Card-CserQom-.js.map} +1 -1
  5. package/dist/{Card-NPXVehHb.cjs → Card-DI2viIxR.cjs} +2 -2
  6. package/dist/{Card-NPXVehHb.cjs.map → Card-DI2viIxR.cjs.map} +1 -1
  7. package/dist/{Card-BGOWR4lW.js → Card-DOcs__XO.js} +2 -2
  8. package/dist/{Card-BGOWR4lW.js.map → Card-DOcs__XO.js.map} +1 -1
  9. package/dist/{Card-CRJ4l5KM.cjs → Card-EqHFqs6U.cjs} +2 -2
  10. package/dist/{Card-CRJ4l5KM.cjs.map → Card-EqHFqs6U.cjs.map} +1 -1
  11. package/dist/{Card-BfvsO78k.js → Card-MDGTBRIk.js} +3 -3
  12. package/dist/{Card-BfvsO78k.js.map → Card-MDGTBRIk.js.map} +1 -1
  13. package/dist/{LockedThumbnail-B8MKBVXz.cjs → LockedThumbnail-CJTQSHKu.cjs} +2 -2
  14. package/dist/{LockedThumbnail-B8MKBVXz.cjs.map → LockedThumbnail-CJTQSHKu.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-Bu9jNPUi.js → LockedThumbnail-Cw7R8xmf.js} +2 -2
  16. package/dist/{LockedThumbnail-Bu9jNPUi.js.map → LockedThumbnail-Cw7R8xmf.js.map} +1 -1
  17. package/dist/assets/index.css +1 -1
  18. package/dist/index-Dy7wBqvi.cjs +2 -0
  19. package/dist/index-Dy7wBqvi.cjs.map +1 -0
  20. package/dist/{index-CJEl_fID.js → index-UtNoTrrb.js} +666 -665
  21. package/dist/index-UtNoTrrb.js.map +1 -0
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.js +1 -1
  24. package/package.json +2 -1
  25. package/src/components/Avatar/index.tsx +1 -4
  26. package/src/components/ChannelList/CustomChannelPreview.tsx +9 -11
  27. package/src/components/ChannelView.tsx +14 -15
  28. package/src/components/CustomMessage/index.tsx +1 -1
  29. package/src/components/CustomMessageInput/index.tsx +1 -1
  30. package/src/components/MessagingShell/MessagingShell.test.tsx +93 -0
  31. package/src/components/MessagingShell/index.tsx +46 -4
  32. package/src/providers/MessagingProvider.tsx +1 -1
  33. package/src/styles.css +53 -10
  34. package/dist/index-CJEl_fID.js.map +0 -1
  35. package/dist/index-D-5Igybf.cjs +0 -2
  36. package/dist/index-D-5Igybf.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-D-5Igybf.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-Dy7wBqvi.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.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-CJEl_fID.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-UtNoTrrb.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",
3
+ "version": "3.1.2-rc-1780563075",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -60,6 +60,7 @@
60
60
  "postcss": "^8.4.49",
61
61
  "react": "^18.3.1",
62
62
  "react-dom": "^18.3.1",
63
+ "sass-embedded": "^1.100.0",
63
64
  "storybook": "^8.5.0",
64
65
  "stream-chat": "^9.41.1",
65
66
  "stream-chat-react": "^13.14.5",
@@ -43,10 +43,7 @@ export const Avatar = ({
43
43
  const borderStyle =
44
44
  shape === 'circle'
45
45
  ? { borderRadius: '50%' }
46
- : {
47
- borderRadius: '33%',
48
- cornerShape: 'superellipse(1.3)',
49
- }
46
+ : { borderRadius: '1rem' }
50
47
 
51
48
  const avatarInner = (
52
49
  <div className="h-full w-full overflow-hidden" style={borderStyle}>
@@ -125,22 +125,21 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
125
125
  onClick={handleClick}
126
126
  onKeyDown={handleKeyDown}
127
127
  className={classNames(
128
- 'group w-full px-4 py-3 transition-colors text-left max-w-full overflow-hidden focus-ring',
128
+ 'group w-full px-4 py-3 transition-colors text-left max-w-full overflow-hidden focus-ring rounded-[12px] [&+&]:mt-2',
129
129
  {
130
- 'bg-primary-alt/10 border-l-4 border-l-primary': isSelected,
131
- 'hover:bg-sand': !isSelected,
130
+ 'bg-black/[0.04]': isSelected,
131
+ 'hover:bg-black/[0.02]': !isSelected,
132
132
  }
133
133
  )}
134
134
  >
135
- <div className="flex items-start gap-3">
135
+ <div className="flex items-start gap-4">
136
136
  {/* Avatar */}
137
137
  <Avatar
138
138
  id={participant?.user?.id || channel.id || 'unknown'}
139
139
  name={participantName}
140
140
  image={participantImage}
141
- size={44}
141
+ size={48}
142
142
  starred={isChannelStarred}
143
- className="[&_.avatar-fallback]:group-hover:bg-[#eeeeee]"
144
143
  />
145
144
 
146
145
  {/* Content column */}
@@ -149,8 +148,7 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
149
148
  <div className="flex items-center justify-between gap-2">
150
149
  <h3
151
150
  className={classNames(
152
- 'text-sm font-medium truncate',
153
- isSelected ? 'text-primary' : 'text-charcoal'
151
+ 'text-sm font-medium truncate text-[#191918]'
154
152
  )}
155
153
  >
156
154
  {isChannelStarred && (
@@ -159,7 +157,7 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
159
157
  {participantName}
160
158
  </h3>
161
159
  {lastMessageTime && (
162
- <span className="text-xs text-stone flex-shrink-0">
160
+ <span className="text-xs text-[#717070] flex-shrink-0">
163
161
  {lastMessageTime}
164
162
  </span>
165
163
  )}
@@ -167,11 +165,11 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
167
165
 
168
166
  {/* Message and unread badge row */}
169
167
  <div className="flex items-center justify-between gap-2 min-w-0">
170
- <p className="text-xs text-stone flex-1 line-clamp-1">
168
+ <p className="text-sm text-[#717070] flex-1 line-clamp-1">
171
169
  {messagePreview}
172
170
  </p>
173
171
  {unreadCount > 0 && (
174
- <span className="bg-[#7f22fe] text-white text-xs px-2 py-0.5 rounded-full min-w-[20px] text-center flex-shrink-0">
172
+ <span className="bg-[#7f22fe] text-white text-[10px] rounded-full h-4 flex items-center justify-center p-1 min-w-4 text-center flex-shrink-0">
175
173
  {unreadCount > 99 ? '99+' : unreadCount}
176
174
  </span>
177
175
  )}
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  ArrowLeftIcon,
3
- CaretRightIcon,
4
3
  DotsThreeIcon,
5
4
  SparkleIcon,
6
5
  StarIcon,
@@ -35,7 +34,7 @@ import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState'
35
34
  import { LoadingState } from './MessagingShell/LoadingState'
36
35
 
37
36
  const ICON_BTN_CLASS =
38
- 'size-10 rounded-full bg-[#F1F0EE] hover:bg-[#E5E4E1] flex items-center justify-center transition-colors duration-150 focus-ring'
37
+ 'size-10 rounded-full hover:bg-[#E5E4E1] flex items-center justify-center transition-colors duration-150 focus-ring'
39
38
 
40
39
  const DM_AGENT_HEADER_HELPER_TEXT = 'Replies instantly with AI assistant'
41
40
 
@@ -90,13 +89,13 @@ const CustomChannelHeader: React.FC<{
90
89
 
91
90
  return (
92
91
  <div className="@container">
93
- <div className="grid grid-cols-[1fr_auto_1fr] w-full items-center @lg:hidden">
92
+ <div className="grid grid-cols-[1fr_auto_1fr] w-full items-center @lg:hidden px-6 py-3">
94
93
  <div className="flex items-center gap-2">
95
94
  {showBackButton && (
96
95
  <button
97
96
  className={classNames(
98
97
  ICON_BTN_CLASS,
99
- 'messaging-channel-view-back-button-mobile'
98
+ 'messaging-channel-view-back-button-mobile bg-[#F1F0EE]'
100
99
  )}
101
100
  onClick={onBack || (() => {})}
102
101
  type="button"
@@ -113,16 +112,15 @@ const CustomChannelHeader: React.FC<{
113
112
  image={participantImage}
114
113
  starred={showStarButton && isStarred}
115
114
  dmAgentEnabled={dmAgentEnabled}
116
- size={40}
115
+ size={48}
117
116
  />
118
117
  <button
119
118
  type="button"
120
119
  onClick={onShowInfo}
121
- className="flex items-center gap-0.5 rounded-full bg-black/[0.05] px-3 py-1 text-xs font-medium text-black/90 hover:bg-black/[0.08] transition-colors"
120
+ className="flex items-center gap-0.5 rounded-full px-3 py-1 text-xs font-medium text-black/90 hover:bg-black/[0.08] transition-colors"
122
121
  aria-label={`View info for ${participantName}`}
123
122
  >
124
123
  {participantName}
125
- <CaretRightIcon className="size-3 shrink-0" />
126
124
  </button>
127
125
  {dmAgentEnabled && (
128
126
  <div className="flex items-center gap-1 text-[10px] leading-3 text-black/55">
@@ -151,7 +149,7 @@ const CustomChannelHeader: React.FC<{
151
149
  </button>
152
150
  )}
153
151
  <button
154
- className={ICON_BTN_CLASS}
152
+ className={classNames(ICON_BTN_CLASS, 'bg-[#F1F0EE]')}
155
153
  onClick={onShowInfo}
156
154
  type="button"
157
155
  aria-label="Show info"
@@ -160,7 +158,7 @@ const CustomChannelHeader: React.FC<{
160
158
  </button>
161
159
  </div>
162
160
  </div>
163
- <div className="hidden @lg:flex items-center justify-between gap-3 min-h-12">
161
+ <div className="px-6 py-3 hidden @lg:flex items-center justify-between gap-3 min-h-12 border-b border-b-black/[0.08]">
164
162
  <div className="flex items-center gap-4 min-w-0">
165
163
  {showBackButton && onBack && (
166
164
  <button
@@ -182,7 +180,7 @@ const CustomChannelHeader: React.FC<{
182
180
  image={participantImage}
183
181
  starred={showStarButton && isStarred}
184
182
  dmAgentEnabled={dmAgentEnabled}
185
- size={40}
183
+ size={48}
186
184
  />
187
185
  <div className="min-w-0">
188
186
  {canShowInfo ? (
@@ -193,7 +191,6 @@ const CustomChannelHeader: React.FC<{
193
191
  aria-label={`View info for ${participantName}`}
194
192
  >
195
193
  <span className="truncate">{participantName}</span>
196
- <CaretRightIcon className="size-4 shrink-0" />
197
194
  </button>
198
195
  ) : (
199
196
  <h1 className="font-medium text-black/90 truncate">
@@ -219,7 +216,7 @@ const CustomChannelHeader: React.FC<{
219
216
  }
220
217
  >
221
218
  <StarIcon
222
- className={classNames('size-5', {
219
+ className={classNames('size-6', {
223
220
  'text-yellow-600': isStarred,
224
221
  'text-black/90': !isStarred,
225
222
  })}
@@ -234,7 +231,7 @@ const CustomChannelHeader: React.FC<{
234
231
  type="button"
235
232
  aria-label="Show info"
236
233
  >
237
- <DotsThreeIcon className="size-5 text-black/90" />
234
+ <DotsThreeIcon className="size-6 text-black/90" />
238
235
  </button>
239
236
  )}
240
237
  </div>
@@ -398,7 +395,7 @@ const ChannelViewInner: React.FC<{
398
395
  >
399
396
  <Window>
400
397
  {/* Custom Channel Header */}
401
- <div key="lt-channel-header" className="p-4">
398
+ <div key="lt-channel-header">
402
399
  <CustomChannelHeader
403
400
  onBack={onBack}
404
401
  showBackButton={showBackButton}
@@ -436,7 +433,9 @@ const ChannelViewInner: React.FC<{
436
433
  {/* Message Input */}
437
434
  <CustomMessageInput
438
435
  key="lt-channel-message-input"
439
- renderActions={() => renderMessageInputActions?.(channel)}
436
+ {...(renderMessageInputActions && {
437
+ renderActions: () => renderMessageInputActions?.(channel),
438
+ })}
440
439
  renderFooter={() => renderMessageInputFooter?.(channel)}
441
440
  disabled={composerDisabled}
442
441
  disabledReason={composerDisabledReason}
@@ -207,7 +207,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
207
207
  id={message.user.id}
208
208
  image={message.user.image}
209
209
  name={message.user.name || message.user.id}
210
- size={isChatbot ? 24 : 28}
210
+ size={24}
211
211
  shape="circle"
212
212
  dmAgentEnabled={isChatbot}
213
213
  />
@@ -60,7 +60,7 @@ const CustomMessageInputInner: React.FC = () => {
60
60
  <div className="w-full ml-2 mr-4 self-center leading-[0]">
61
61
  <TextareaComposer
62
62
  aria-disabled={disabled || undefined}
63
- className="w-full resize-none outline-none leading-6"
63
+ className="w-full resize-none outline-none leading-5 placeholder:text-black/30 text-sm"
64
64
  // While this might usually be considered an anti-pattern, in most
65
65
  // cases, when a message thread is rendered, we want the input to
66
66
  // gain focus automatically.
@@ -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
 
@@ -340,7 +340,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
340
340
  client={client}
341
341
  customClasses={{
342
342
  channelList:
343
- 'str-chat__channel-list str-chat__channel-list-react bg-transparent lg:border-r-2 border-r-0 border-[#0000000A]',
343
+ 'str-chat__channel-list str-chat__channel-list-react bg-transparent lg:border-r-2 border-r-0 border-[#0000000A] p-4',
344
344
  }}
345
345
  >
346
346
  {children}
package/src/styles.css CHANGED
@@ -1,11 +1,59 @@
1
+ @layer stream, stream-overrides;
2
+
1
3
  /* Stream Chat base styles */
2
- @import 'stream-chat-react/dist/css/v2/index.css';
4
+ @import 'stream-chat-react/dist/scss/v2/index.scss' layer(stream);
3
5
 
4
- /* Inherit the host's font instead of stream-chat-react's hardcoded system stack.
6
+ @layer stream-overrides {
7
+ .str-chat {
8
+ /* Inherit the host's font instead of stream-chat-react's hardcoded system stack.
5
9
  In admin (federation host) this resolves to Link Sans; on linktr.ee profile
6
10
  pages it resolves to the creator's profile font. */
7
- .str-chat {
8
- --str-chat__font-family: inherit;
11
+ --str-chat__font-family: inherit;
12
+
13
+ --str-chat__message-bubble-border-radius: 1.5rem;
14
+ --str-chat__message-bubble-background-color: #f1f0ee;
15
+ --str-chat__message-bubble-color: #181818;
16
+
17
+ --str-chat__own-message-bubble-background-color: #121110;
18
+ --str-chat__own-message-bubble-color: #fff;
19
+ }
20
+
21
+ .str-chat__message.str-chat__message--other {
22
+ column-gap: 0.25rem;
23
+ }
24
+
25
+ .str-chat__message-text {
26
+ padding: 0.75rem 1rem;
27
+ }
28
+
29
+ .str-chat__message-bubble {
30
+ font-size: 0.875rem;
31
+ line-height: 1.25rem;
32
+ }
33
+
34
+ .str-chat__li--single,
35
+ .str-chat__li--bottom {
36
+ margin-block-end: 0.875rem;
37
+
38
+ .str-chat__message--other .str-chat__message-bubble {
39
+ border-end-start-radius: var(--str-chat__message-bubble-border-radius);
40
+ }
41
+
42
+ .str-chat__message--me .str-chat__message-bubble {
43
+ border-end-end-radius: var(--str-chat__message-bubble-border-radius);
44
+ }
45
+ }
46
+
47
+ .str-chat__date-separator {
48
+ .str-chat__date-separator-line {
49
+ display: none;
50
+ }
51
+
52
+ .str-chat__date-separator-date {
53
+ margin: 0 auto;
54
+ font-size: 0.75rem;
55
+ }
56
+ }
9
57
  }
10
58
 
11
59
  /* Dialog component styles - used by messaging components */
@@ -293,11 +341,6 @@
293
341
  }
294
342
 
295
343
  .str-chat__message.str-chat__message--me {
296
- .str-chat__message-bubble {
297
- background-color: #121110;
298
- color: white;
299
- }
300
-
301
344
  .str-chat__attachment-list .str-chat__message-attachment--card {
302
345
  color: white;
303
346
  }
@@ -594,7 +637,7 @@
594
637
  .str-chat__message-text
595
638
  .str-chat__message-text-inner
596
639
  p {
597
- margin: 0.25rem 0;
640
+ margin: 0;
598
641
  }
599
642
 
600
643
  .str-chat__message