@linktr.ee/messaging-react 3.1.3 → 3.1.4-rc-1780636753

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 (30) hide show
  1. package/README.md +18 -1
  2. package/dist/{Card-DvoK42pX.cjs → Card-1U2tLPcp.cjs} +2 -2
  3. package/dist/{Card-DvoK42pX.cjs.map → Card-1U2tLPcp.cjs.map} +1 -1
  4. package/dist/{Card-RHd97_iq.js → Card-B9atg4sP.js} +2 -2
  5. package/dist/{Card-RHd97_iq.js.map → Card-B9atg4sP.js.map} +1 -1
  6. package/dist/{Card-CqeFyk7l.cjs → Card-BkgsPkp4.cjs} +2 -2
  7. package/dist/{Card-CqeFyk7l.cjs.map → Card-BkgsPkp4.cjs.map} +1 -1
  8. package/dist/{Card-Ctub3AU9.js → Card-BwFdJXYm.js} +2 -2
  9. package/dist/{Card-Ctub3AU9.js.map → Card-BwFdJXYm.js.map} +1 -1
  10. package/dist/{Card-BHF-XfHG.cjs → Card-D_XOj1eE.cjs} +2 -2
  11. package/dist/{Card-BHF-XfHG.cjs.map → Card-D_XOj1eE.cjs.map} +1 -1
  12. package/dist/{Card-VEde2Hfe.js → Card-jyXjZZ0u.js} +3 -3
  13. package/dist/{Card-VEde2Hfe.js.map → Card-jyXjZZ0u.js.map} +1 -1
  14. package/dist/{LockedThumbnail-BADzjHNM.js → LockedThumbnail-Dwt_goCX.js} +2 -2
  15. package/dist/{LockedThumbnail-BADzjHNM.js.map → LockedThumbnail-Dwt_goCX.js.map} +1 -1
  16. package/dist/{LockedThumbnail-BPMP5yZP.cjs → LockedThumbnail-oxtdpgut.cjs} +2 -2
  17. package/dist/{LockedThumbnail-BPMP5yZP.cjs.map → LockedThumbnail-oxtdpgut.cjs.map} +1 -1
  18. package/dist/{index-CnOvDQIp.js → index-CO975B6P.js} +908 -868
  19. package/dist/{index-CnOvDQIp.js.map → index-CO975B6P.js.map} +1 -1
  20. package/dist/index-D4Dse1Lu.cjs +2 -0
  21. package/dist/{index-BQf4WkPC.cjs.map → index-D4Dse1Lu.cjs.map} +1 -1
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.d.ts +38 -0
  24. package/dist/index.js +1 -1
  25. package/package.json +1 -1
  26. package/src/components/ChannelList/index.test.tsx +151 -3
  27. package/src/components/ChannelList/index.tsx +72 -4
  28. package/src/index.ts +1 -0
  29. package/src/types.ts +38 -0
  30. package/dist/index-BQf4WkPC.cjs +0 -2
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-BQf4WkPC.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-D4Dse1Lu.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
@@ -95,6 +95,36 @@ export declare const ChannelEmptyState: default_2.FC;
95
95
  */
96
96
  export declare const ChannelList: default_2.NamedExoticComponent<ChannelListProps>;
97
97
 
98
+ /**
99
+ * Derived state reported by the mounted ChannelList instance.
100
+ *
101
+ * This reflects the currently loaded list data only. It does not guarantee
102
+ * complete server-side truth for channels outside the mounted list's current
103
+ * query window or cache.
104
+ */
105
+ export declare interface ChannelListDerivedState {
106
+ /**
107
+ * Whether the mounted list has completed its first channel-resolution pass.
108
+ */
109
+ isInitialLoadSettled: boolean;
110
+ /**
111
+ * Visible channels after `channelRenderFilterFn` is applied.
112
+ */
113
+ channels: Channel[];
114
+ /**
115
+ * Raw channels received from Stream before client-side filtering.
116
+ */
117
+ rawChannels: Channel[];
118
+ /**
119
+ * Convenience flag for empty-state consumers. Mirrors `channels.length > 0`.
120
+ */
121
+ hasChannels: boolean;
122
+ /**
123
+ * Number of visible channels with at least one unread message.
124
+ */
125
+ unreadCount: number;
126
+ }
127
+
98
128
  /**
99
129
  * ChannelList component props
100
130
  */
@@ -137,6 +167,14 @@ export declare interface ChannelListProps {
137
167
  * Falls back to message.text when no matching translation exists.
138
168
  */
139
169
  viewerLanguage?: string;
170
+ /**
171
+ * Reports derived state from the mounted Stream ChannelList instance.
172
+ *
173
+ * This callback is driven by currently loaded list data only. Use it for
174
+ * cache/list-derived UI state such as empty-state confirmation or visible
175
+ * unread badges; do not assume it represents complete server-side truth.
176
+ */
177
+ onStateChange?: (state: ChannelListDerivedState) => void;
140
178
  }
141
179
 
142
180
  /**
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-CnOvDQIp.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-CO975B6P.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.3",
3
+ "version": "3.1.4-rc-1780636753",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -1,3 +1,4 @@
1
+ import { act, waitFor } from '@testing-library/react'
1
2
  import React from 'react'
2
3
  import type { Channel } from 'stream-chat'
3
4
  import { describe, expect, it, vi, beforeEach } from 'vitest'
@@ -56,7 +57,7 @@ describe('ChannelList', () => {
56
57
  })
57
58
  })
58
59
 
59
- it('wraps channelRenderFilterFn to restore pending messages and delegates to consumer filter', () => {
60
+ it('wraps channelRenderFilterFn to restore pending messages and delegates to consumer filter', async () => {
60
61
  const filterFn = vi.fn((channels: Channel[]) => channels)
61
62
 
62
63
  renderWithProviders(
@@ -77,9 +78,156 @@ describe('ChannelList', () => {
77
78
 
78
79
  // When the wrapper is called, it should delegate to the consumer's filter
79
80
  const mockChannels = [
80
- { cid: 'ch-1', state: { pending_messages: [], addMessageSorted: vi.fn() } },
81
+ {
82
+ cid: 'ch-1',
83
+ countUnread: () => 0,
84
+ state: { pending_messages: [], addMessageSorted: vi.fn() },
85
+ },
81
86
  ]
82
- streamProps.channelRenderFilterFn!(mockChannels)
87
+ await act(async () => {
88
+ streamProps.channelRenderFilterFn!(mockChannels)
89
+ await Promise.resolve()
90
+ })
83
91
  expect(filterFn).toHaveBeenCalledWith(mockChannels)
84
92
  })
93
+
94
+ it('notifies hosts with derived visible-channel state', async () => {
95
+ const onStateChange = vi.fn()
96
+ const visibleChannel = {
97
+ cid: 'ch-visible',
98
+ countUnread: () => 2,
99
+ state: { pending_messages: [], addMessageSorted: vi.fn() },
100
+ }
101
+ const hiddenChannel = {
102
+ cid: 'ch-hidden',
103
+ countUnread: () => 7,
104
+ state: { pending_messages: [], addMessageSorted: vi.fn() },
105
+ }
106
+ const filterFn = vi.fn((channels: Channel[]) => channels.slice(0, 1))
107
+
108
+ renderWithProviders(
109
+ React.createElement(ChannelList, {
110
+ ...defaultProps,
111
+ channelRenderFilterFn: filterFn,
112
+ onStateChange,
113
+ } as never)
114
+ )
115
+
116
+ const streamProps = streamChannelListMock.mock.calls[0][0] as {
117
+ channelRenderFilterFn?: (channels: Channel[]) => Channel[]
118
+ }
119
+
120
+ await act(async () => {
121
+ streamProps.channelRenderFilterFn!([
122
+ visibleChannel as unknown as Channel,
123
+ hiddenChannel as unknown as Channel,
124
+ ])
125
+ await Promise.resolve()
126
+ })
127
+
128
+ await waitFor(() => {
129
+ expect(onStateChange).toHaveBeenLastCalledWith({
130
+ isInitialLoadSettled: true,
131
+ channels: [visibleChannel],
132
+ rawChannels: [visibleChannel, hiddenChannel],
133
+ hasChannels: true,
134
+ unreadCount: 1,
135
+ })
136
+ })
137
+ })
138
+
139
+ it('reports a settled empty state when no visible channels remain', async () => {
140
+ const onStateChange = vi.fn()
141
+ const filterFn = vi.fn(() => [])
142
+ const hiddenChannel = {
143
+ cid: 'ch-hidden',
144
+ countUnread: () => 3,
145
+ state: { pending_messages: [], addMessageSorted: vi.fn() },
146
+ }
147
+
148
+ renderWithProviders(
149
+ React.createElement(ChannelList, {
150
+ ...defaultProps,
151
+ channelRenderFilterFn: filterFn,
152
+ onStateChange,
153
+ } as never)
154
+ )
155
+
156
+ const streamProps = streamChannelListMock.mock.calls[0][0] as {
157
+ channelRenderFilterFn?: (channels: Channel[]) => Channel[]
158
+ }
159
+
160
+ await act(async () => {
161
+ streamProps.channelRenderFilterFn!([hiddenChannel as unknown as Channel])
162
+ await Promise.resolve()
163
+ })
164
+
165
+ await waitFor(() => {
166
+ expect(onStateChange).toHaveBeenLastCalledWith({
167
+ isInitialLoadSettled: true,
168
+ channels: [],
169
+ rawChannels: [hiddenChannel],
170
+ hasChannels: false,
171
+ unreadCount: 0,
172
+ })
173
+ })
174
+ })
175
+
176
+ it('resets derived state when filters change before the new list resolves', async () => {
177
+ const onStateChange = vi.fn()
178
+ const firstChannel = {
179
+ cid: 'ch-first',
180
+ countUnread: () => 1,
181
+ state: { pending_messages: [], addMessageSorted: vi.fn() },
182
+ }
183
+
184
+ const { rerender } = renderWithProviders(
185
+ React.createElement(ChannelList, {
186
+ ...defaultProps,
187
+ onStateChange,
188
+ } as never)
189
+ )
190
+
191
+ let streamProps = streamChannelListMock.mock.calls.at(-1)?.[0] as {
192
+ channelRenderFilterFn?: (channels: Channel[]) => Channel[]
193
+ }
194
+
195
+ await act(async () => {
196
+ streamProps.channelRenderFilterFn!([firstChannel as unknown as Channel])
197
+ await Promise.resolve()
198
+ })
199
+
200
+ await waitFor(() => {
201
+ expect(onStateChange).toHaveBeenLastCalledWith({
202
+ isInitialLoadSettled: true,
203
+ channels: [firstChannel],
204
+ rawChannels: [firstChannel],
205
+ hasChannels: true,
206
+ unreadCount: 1,
207
+ })
208
+ })
209
+
210
+ rerender(
211
+ React.createElement(ChannelList, {
212
+ ...defaultProps,
213
+ filters: { type: 'messaging', frozen: true },
214
+ onStateChange,
215
+ } as never)
216
+ )
217
+
218
+ streamProps = streamChannelListMock.mock.calls.at(-1)?.[0] as {
219
+ channelRenderFilterFn?: (channels: Channel[]) => Channel[]
220
+ }
221
+ expect(typeof streamProps.channelRenderFilterFn).toBe('function')
222
+
223
+ await waitFor(() => {
224
+ expect(onStateChange).toHaveBeenLastCalledWith({
225
+ isInitialLoadSettled: false,
226
+ channels: [],
227
+ rawChannels: [],
228
+ hasChannels: false,
229
+ unreadCount: 0,
230
+ })
231
+ })
232
+ })
85
233
  })
@@ -5,13 +5,21 @@ import { ChannelList as StreamChannelList } from 'stream-chat-react'
5
5
 
6
6
  import { restorePendingMessages } from '../../hooks/useRestorePendingMessages'
7
7
  import { useMessagingContext } from '../../providers/MessagingProvider'
8
- import type { ChannelListProps } from '../../types'
8
+ import type { ChannelListDerivedState, ChannelListProps } from '../../types'
9
9
 
10
10
  import { ChannelListProvider } from './ChannelListContext'
11
11
  import CustomChannelPreview from './CustomChannelPreview'
12
12
 
13
13
  const DEFAULT_SORT = { last_message_at: -1 } as const
14
14
 
15
+ const EMPTY_DERIVED_STATE: ChannelListDerivedState = {
16
+ isInitialLoadSettled: false,
17
+ channels: [],
18
+ rawChannels: [],
19
+ hasChannels: false,
20
+ unreadCount: 0,
21
+ }
22
+
15
23
  /**
16
24
  * Channel list component with customizable header and actions
17
25
  */
@@ -27,6 +35,7 @@ export const ChannelList = React.memo<ChannelListProps>(
27
35
  customEmptyStateIndicator,
28
36
  renderMessagePreview,
29
37
  viewerLanguage,
38
+ onStateChange,
30
39
  }) => {
31
40
  // Track renders
32
41
  const renderCountRef = React.useRef(0)
@@ -34,6 +43,42 @@ export const ChannelList = React.memo<ChannelListProps>(
34
43
 
35
44
  // Get debug flag from context
36
45
  const { debug = false } = useMessagingContext()
46
+ const [derivedState, setDerivedState] = React.useState<ChannelListDerivedState>(
47
+ EMPTY_DERIVED_STATE
48
+ )
49
+ const listIdentityKey = React.useMemo(
50
+ () => `${JSON.stringify(filters)}:${JSON.stringify(sort)}`,
51
+ [filters, sort]
52
+ )
53
+ const pendingDerivedStateRef = React.useRef<ChannelListDerivedState>(
54
+ EMPTY_DERIVED_STATE
55
+ )
56
+ const publishScheduledRef = React.useRef(false)
57
+ const listVersionRef = React.useRef(0)
58
+
59
+ const publishDerivedState = React.useCallback(
60
+ (nextState: ChannelListDerivedState) => {
61
+ const publishVersion = listVersionRef.current
62
+ pendingDerivedStateRef.current = nextState
63
+ if (publishScheduledRef.current) {
64
+ return
65
+ }
66
+
67
+ publishScheduledRef.current = true
68
+ queueMicrotask(() => {
69
+ if (publishVersion !== listVersionRef.current) {
70
+ publishScheduledRef.current = false
71
+ return
72
+ }
73
+ publishScheduledRef.current = false
74
+ setDerivedState((currentState) => {
75
+ const pendingState = pendingDerivedStateRef.current
76
+ return currentState === pendingState ? currentState : pendingState
77
+ })
78
+ })
79
+ },
80
+ []
81
+ )
37
82
 
38
83
  // Wrap channelRenderFilterFn to restore pending messages for all channels
39
84
  // as soon as they are loaded, without waiting for the user to click into each one.
@@ -42,11 +87,23 @@ export const ChannelList = React.memo<ChannelListProps>(
42
87
  for (const channel of channels) {
43
88
  restorePendingMessages(channel)
44
89
  }
45
- return channelRenderFilterFn
90
+
91
+ const visibleChannels = channelRenderFilterFn
46
92
  ? channelRenderFilterFn(channels)
47
93
  : channels
94
+
95
+ publishDerivedState({
96
+ isInitialLoadSettled: true,
97
+ channels: visibleChannels,
98
+ rawChannels: channels,
99
+ hasChannels: visibleChannels.length > 0,
100
+ unreadCount: visibleChannels.filter((channel) => channel.countUnread() > 0)
101
+ .length,
102
+ })
103
+
104
+ return visibleChannels
48
105
  },
49
- [channelRenderFilterFn]
106
+ [channelRenderFilterFn, publishDerivedState]
50
107
  )
51
108
 
52
109
  if (debug) {
@@ -74,6 +131,17 @@ export const ChannelList = React.memo<ChannelListProps>(
74
131
  ]
75
132
  )
76
133
 
134
+ React.useEffect(() => {
135
+ listVersionRef.current += 1
136
+ pendingDerivedStateRef.current = EMPTY_DERIVED_STATE
137
+ publishScheduledRef.current = false
138
+ setDerivedState(EMPTY_DERIVED_STATE)
139
+ }, [listIdentityKey])
140
+
141
+ React.useEffect(() => {
142
+ onStateChange?.(derivedState)
143
+ }, [derivedState, onStateChange])
144
+
77
145
  return (
78
146
  <div
79
147
  className={classNames(
@@ -85,7 +153,7 @@ export const ChannelList = React.memo<ChannelListProps>(
85
153
  <div className="flex-1 overflow-hidden min-w-0">
86
154
  <ChannelListProvider value={contextValue}>
87
155
  <StreamChannelList
88
- key={`${JSON.stringify(filters)}:${JSON.stringify(sort)}`}
156
+ key={listIdentityKey}
89
157
  filters={filters}
90
158
  sort={sort}
91
159
  options={{ limit: 30 }}
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export type { ParticipantDisplayUser } from './utils/resolveParticipantDisplayNa
48
48
 
49
49
  // Types
50
50
  export type {
51
+ ChannelListDerivedState,
51
52
  MessagingShellProps,
52
53
  ChannelListProps,
53
54
  ChannelViewProps,
package/src/types.ts CHANGED
@@ -52,6 +52,36 @@ export interface MessagingCapabilities {
52
52
  showDeleteConversation?: boolean
53
53
  }
54
54
 
55
+ /**
56
+ * Derived state reported by the mounted ChannelList instance.
57
+ *
58
+ * This reflects the currently loaded list data only. It does not guarantee
59
+ * complete server-side truth for channels outside the mounted list's current
60
+ * query window or cache.
61
+ */
62
+ export interface ChannelListDerivedState {
63
+ /**
64
+ * Whether the mounted list has completed its first channel-resolution pass.
65
+ */
66
+ isInitialLoadSettled: boolean
67
+ /**
68
+ * Visible channels after `channelRenderFilterFn` is applied.
69
+ */
70
+ channels: Channel[]
71
+ /**
72
+ * Raw channels received from Stream before client-side filtering.
73
+ */
74
+ rawChannels: Channel[]
75
+ /**
76
+ * Convenience flag for empty-state consumers. Mirrors `channels.length > 0`.
77
+ */
78
+ hasChannels: boolean
79
+ /**
80
+ * Number of visible channels with at least one unread message.
81
+ */
82
+ unreadCount: number
83
+ }
84
+
55
85
  /**
56
86
  * ChannelList component props
57
87
  */
@@ -97,6 +127,14 @@ export interface ChannelListProps {
97
127
  * Falls back to message.text when no matching translation exists.
98
128
  */
99
129
  viewerLanguage?: string
130
+ /**
131
+ * Reports derived state from the mounted Stream ChannelList instance.
132
+ *
133
+ * This callback is driven by currently loaded list data only. Use it for
134
+ * cache/list-derived UI state such as empty-state confirmation or visible
135
+ * unread badges; do not assume it represents complete server-side truth.
136
+ */
137
+ onStateChange?: (state: ChannelListDerivedState) => void
100
138
  }
101
139
 
102
140
  /**