@linktr.ee/messaging-react 1.0.0 → 1.0.2
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/index.js +149 -140
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/components/ActionButton/ActionButton.stories.tsx +46 -0
- package/src/components/ActionButton/ActionButton.test.tsx +112 -0
- package/src/components/ActionButton/index.tsx +33 -0
- package/src/components/Avatar/Avatar.stories.tsx +144 -0
- package/src/components/Avatar/avatarColors.ts +35 -0
- package/src/components/Avatar/index.tsx +64 -0
- package/src/components/ChannelList/ChannelList.stories.tsx +48 -0
- package/src/components/ChannelList/CustomChannelPreview.stories.tsx +303 -0
- package/src/components/ChannelList/CustomChannelPreview.tsx +121 -0
- package/src/components/ChannelList/index.tsx +129 -0
- package/src/components/ChannelView.tsx +422 -0
- package/src/components/CloseButton/index.tsx +16 -0
- package/src/components/IconButton/IconButton.stories.tsx +40 -0
- package/src/components/IconButton/index.tsx +32 -0
- package/src/components/Loading/Loading.stories.tsx +24 -0
- package/src/components/Loading/index.tsx +50 -0
- package/src/components/MessagingShell/EmptyState.stories.tsx +38 -0
- package/src/components/MessagingShell/EmptyState.tsx +58 -0
- package/src/components/MessagingShell/ErrorState.stories.tsx +42 -0
- package/src/components/MessagingShell/ErrorState.tsx +33 -0
- package/src/components/MessagingShell/LoadingState.stories.tsx +26 -0
- package/src/components/MessagingShell/LoadingState.tsx +15 -0
- package/src/components/MessagingShell/index.tsx +298 -0
- package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +188 -0
- package/src/components/ParticipantPicker/ParticipantItem.tsx +59 -0
- package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +54 -0
- package/src/components/ParticipantPicker/ParticipantPicker.tsx +196 -0
- package/src/components/ParticipantPicker/index.tsx +234 -0
- package/src/components/SearchInput/SearchInput.stories.tsx +33 -0
- package/src/components/SearchInput/SearchInput.test.tsx +108 -0
- package/src/components/SearchInput/index.tsx +50 -0
- package/src/hooks/useMessaging.ts +9 -0
- package/src/hooks/useParticipants.ts +92 -0
- package/src/index.ts +26 -0
- package/src/providers/MessagingProvider.tsx +282 -0
- package/src/stories/mocks.tsx +157 -0
- package/src/test/setup.ts +30 -0
- package/src/test/utils.tsx +23 -0
- package/src/types.ts +113 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import type { ParticipantSource, Participant } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook for managing participant loading with search and pagination
|
|
6
|
+
*/
|
|
7
|
+
export const useParticipants = (
|
|
8
|
+
participantSource: ParticipantSource,
|
|
9
|
+
options: {
|
|
10
|
+
initialSearch?: string;
|
|
11
|
+
pageSize?: number;
|
|
12
|
+
} = {}
|
|
13
|
+
) => {
|
|
14
|
+
const { initialSearch = '', pageSize = 20 } = options;
|
|
15
|
+
|
|
16
|
+
const [participants, setParticipants] = useState<Participant[]>([]);
|
|
17
|
+
const [loading, setLoading] = useState(false);
|
|
18
|
+
const [error, setError] = useState<string | null>(null);
|
|
19
|
+
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
|
20
|
+
const [hasMore, setHasMore] = useState(true);
|
|
21
|
+
const [cursor, setCursor] = useState<string | undefined>();
|
|
22
|
+
|
|
23
|
+
// Load participants with current search query
|
|
24
|
+
const loadParticipants = useCallback(async (
|
|
25
|
+
reset = false,
|
|
26
|
+
customSearch?: string
|
|
27
|
+
) => {
|
|
28
|
+
if (loading) return;
|
|
29
|
+
|
|
30
|
+
const search = customSearch !== undefined ? customSearch : searchQuery;
|
|
31
|
+
|
|
32
|
+
setLoading(true);
|
|
33
|
+
setError(null);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const result = await participantSource.loadParticipants({
|
|
37
|
+
search: search || undefined,
|
|
38
|
+
limit: pageSize,
|
|
39
|
+
cursor: reset ? undefined : cursor,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
setParticipants(prev =>
|
|
43
|
+
reset ? result.participants : [...prev, ...result.participants]
|
|
44
|
+
);
|
|
45
|
+
setHasMore(result.hasMore);
|
|
46
|
+
setCursor(result.nextCursor);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const errorMessage = err instanceof Error ? err.message : 'Failed to load participants';
|
|
49
|
+
setError(errorMessage);
|
|
50
|
+
console.error('[useParticipants] Load error:', err);
|
|
51
|
+
} finally {
|
|
52
|
+
setLoading(false);
|
|
53
|
+
}
|
|
54
|
+
}, [participantSource, searchQuery, cursor, pageSize, loading]);
|
|
55
|
+
|
|
56
|
+
// Load more participants (pagination)
|
|
57
|
+
const loadMore = useCallback(() => {
|
|
58
|
+
if (hasMore && !loading) {
|
|
59
|
+
loadParticipants(false);
|
|
60
|
+
}
|
|
61
|
+
}, [hasMore, loading, loadParticipants]);
|
|
62
|
+
|
|
63
|
+
// Search participants
|
|
64
|
+
const search = useCallback((query: string) => {
|
|
65
|
+
setSearchQuery(query);
|
|
66
|
+
setCursor(undefined);
|
|
67
|
+
loadParticipants(true, query);
|
|
68
|
+
}, [loadParticipants]);
|
|
69
|
+
|
|
70
|
+
// Refresh participants
|
|
71
|
+
const refresh = useCallback(() => {
|
|
72
|
+
setCursor(undefined);
|
|
73
|
+
loadParticipants(true);
|
|
74
|
+
}, [loadParticipants]);
|
|
75
|
+
|
|
76
|
+
// Initial load - only run once when participantSource changes
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
loadParticipants(true);
|
|
79
|
+
}, [participantSource.loadParticipants]); // Only depend on the function to avoid loops
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
participants,
|
|
83
|
+
loading,
|
|
84
|
+
error,
|
|
85
|
+
searchQuery,
|
|
86
|
+
hasMore,
|
|
87
|
+
totalCount: participantSource.totalCount,
|
|
88
|
+
loadMore,
|
|
89
|
+
search,
|
|
90
|
+
refresh,
|
|
91
|
+
};
|
|
92
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export { MessagingShell } from './components/MessagingShell';
|
|
3
|
+
export { ChannelList } from './components/ChannelList';
|
|
4
|
+
export { ChannelView } from './components/ChannelView';
|
|
5
|
+
export { ParticipantPicker } from './components/ParticipantPicker';
|
|
6
|
+
export { Avatar } from './components/Avatar';
|
|
7
|
+
|
|
8
|
+
// Providers
|
|
9
|
+
export { MessagingProvider } from './providers/MessagingProvider';
|
|
10
|
+
|
|
11
|
+
// Hooks
|
|
12
|
+
export { useMessaging } from './hooks/useMessaging';
|
|
13
|
+
export { useParticipants } from './hooks/useParticipants';
|
|
14
|
+
|
|
15
|
+
// Types
|
|
16
|
+
export type {
|
|
17
|
+
MessagingShellProps,
|
|
18
|
+
ChannelListProps,
|
|
19
|
+
ChannelViewProps,
|
|
20
|
+
ParticipantPickerProps,
|
|
21
|
+
MessagingProviderProps,
|
|
22
|
+
MessagingCapabilities,
|
|
23
|
+
ParticipantSource,
|
|
24
|
+
Participant
|
|
25
|
+
} from './types';
|
|
26
|
+
export type { AvatarProps } from './components/Avatar';
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState, useRef, useCallback } from 'react';
|
|
2
|
+
import type { Channel } from 'stream-chat';
|
|
3
|
+
import { Chat } from 'stream-chat-react';
|
|
4
|
+
import { StreamChatService } from '@linktr.ee/messaging-core';
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
MessagingProviderProps,
|
|
8
|
+
MessagingCapabilities,
|
|
9
|
+
MessagingCustomization
|
|
10
|
+
} from '../types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Context value for messaging state and service
|
|
14
|
+
*/
|
|
15
|
+
export interface MessagingContextValue {
|
|
16
|
+
service: StreamChatService | null;
|
|
17
|
+
client: any; // Stream Chat client
|
|
18
|
+
isConnected: boolean;
|
|
19
|
+
isLoading: boolean;
|
|
20
|
+
error: string | null;
|
|
21
|
+
capabilities: MessagingCapabilities;
|
|
22
|
+
customization: MessagingCustomization;
|
|
23
|
+
refreshConnection: () => Promise<void>;
|
|
24
|
+
debug: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MessagingContext = createContext<MessagingContextValue>({
|
|
28
|
+
service: null,
|
|
29
|
+
client: null,
|
|
30
|
+
isConnected: false,
|
|
31
|
+
isLoading: false,
|
|
32
|
+
error: null,
|
|
33
|
+
capabilities: {},
|
|
34
|
+
customization: {},
|
|
35
|
+
refreshConnection: async () => {},
|
|
36
|
+
debug: false,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hook to access messaging context
|
|
41
|
+
*/
|
|
42
|
+
export const useMessagingContext = () => useContext(MessagingContext);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Provider component that wraps messaging-core with React state management
|
|
46
|
+
*/
|
|
47
|
+
export const MessagingProvider: React.FC<MessagingProviderProps> = ({
|
|
48
|
+
children,
|
|
49
|
+
user,
|
|
50
|
+
serviceConfig,
|
|
51
|
+
apiKey,
|
|
52
|
+
capabilities = {},
|
|
53
|
+
customization = {},
|
|
54
|
+
debug = false,
|
|
55
|
+
}) => {
|
|
56
|
+
// Create debug logger that respects the debug prop
|
|
57
|
+
const debugLog = (message: string, ...args: any[]) => {
|
|
58
|
+
if (debug) {
|
|
59
|
+
console.log(`🔥 [MessagingProvider] ${message}`, ...args);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
debugLog('🔄 RENDER START', {
|
|
64
|
+
userId: user?.id,
|
|
65
|
+
apiKey: apiKey?.substring(0, 8) + '...',
|
|
66
|
+
serviceConfig: !!serviceConfig,
|
|
67
|
+
capabilities: Object.keys(capabilities),
|
|
68
|
+
customization: Object.keys(customization)
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const [service, setService] = useState<StreamChatService | null>(null);
|
|
72
|
+
const [client, setClient] = useState<any>(null);
|
|
73
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
74
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
75
|
+
const [error, setError] = useState<string | null>(null);
|
|
76
|
+
|
|
77
|
+
// Prevent multiple concurrent connection attempts
|
|
78
|
+
const connectingRef = useRef(false);
|
|
79
|
+
|
|
80
|
+
// Track renders and prop changes
|
|
81
|
+
const prevPropsRef = useRef({ userId: user?.id, apiKey, serviceConfig, capabilities, customization });
|
|
82
|
+
const renderCountRef = useRef(0);
|
|
83
|
+
renderCountRef.current++;
|
|
84
|
+
|
|
85
|
+
debugLog('📊 RENDER INFO', {
|
|
86
|
+
renderCount: renderCountRef.current,
|
|
87
|
+
currentProps: { userId: user?.id, apiKey: apiKey?.substring(0, 8) + '...' },
|
|
88
|
+
propChanges: {
|
|
89
|
+
userChanged: prevPropsRef.current.userId !== user?.id,
|
|
90
|
+
apiKeyChanged: prevPropsRef.current.apiKey !== apiKey,
|
|
91
|
+
serviceConfigChanged: prevPropsRef.current.serviceConfig !== serviceConfig,
|
|
92
|
+
capabilitiesChanged: prevPropsRef.current.capabilities !== capabilities,
|
|
93
|
+
customizationChanged: prevPropsRef.current.customization !== customization
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
prevPropsRef.current = { userId: user?.id, apiKey, serviceConfig, capabilities, customization };
|
|
98
|
+
|
|
99
|
+
// Initialize service when config changes
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const currentRender = renderCountRef.current;
|
|
102
|
+
debugLog('🔧 SERVICE INIT EFFECT TRIGGERED', {
|
|
103
|
+
renderCount: currentRender,
|
|
104
|
+
apiKey: !!apiKey,
|
|
105
|
+
serviceConfig: !!serviceConfig,
|
|
106
|
+
dependencies: {
|
|
107
|
+
apiKey: apiKey?.substring(0, 8) + '...',
|
|
108
|
+
serviceConfigRef: serviceConfig,
|
|
109
|
+
serviceConfigStable: prevPropsRef.current.serviceConfig === serviceConfig,
|
|
110
|
+
apiKeyStable: prevPropsRef.current.apiKey === apiKey
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!apiKey || !serviceConfig) {
|
|
115
|
+
debugLog('⚠️ SERVICE INIT SKIPPED', { renderCount: currentRender, reason: 'Missing apiKey or serviceConfig' });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
debugLog('🚀 CREATING NEW SERVICE', {
|
|
120
|
+
renderCount: currentRender,
|
|
121
|
+
apiKey: apiKey?.substring(0, 8) + '...',
|
|
122
|
+
serviceConfigChanged: prevPropsRef.current.serviceConfig !== serviceConfig
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const newService = new StreamChatService({
|
|
126
|
+
...serviceConfig,
|
|
127
|
+
apiKey,
|
|
128
|
+
debug,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
setService(newService);
|
|
132
|
+
debugLog('✅ SERVICE SET', { renderCount: currentRender, serviceInstance: !!newService });
|
|
133
|
+
|
|
134
|
+
return () => {
|
|
135
|
+
debugLog('🧹 SERVICE CLEANUP', { renderCount: currentRender, reason: 'Effect cleanup' });
|
|
136
|
+
newService.disconnectUser().catch(console.error);
|
|
137
|
+
};
|
|
138
|
+
}, [apiKey, serviceConfig]); // Use serviceConfig object directly, not individual properties
|
|
139
|
+
|
|
140
|
+
// Track if we've already connected this user with this service to prevent duplicate connections
|
|
141
|
+
const connectedUserRef = useRef<{serviceId: any, userId: string} | null>(null);
|
|
142
|
+
|
|
143
|
+
// Connect user when service and user are available
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
debugLog('🔗 USER CONNECTION EFFECT TRIGGERED', {
|
|
146
|
+
hasService: !!service,
|
|
147
|
+
hasUser: !!user,
|
|
148
|
+
userId: user?.id,
|
|
149
|
+
isConnecting: connectingRef.current,
|
|
150
|
+
isConnected: isConnected,
|
|
151
|
+
dependencies: { service: !!service, userId: user?.id }
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!service || !user) {
|
|
155
|
+
debugLog('⚠️ USER CONNECTION SKIPPED', 'Missing service or user');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (connectingRef.current) {
|
|
160
|
+
debugLog('⚠️ USER CONNECTION SKIPPED', 'Already connecting');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if we've already connected this exact user with this exact service instance
|
|
165
|
+
if (connectedUserRef.current?.serviceId === service && connectedUserRef.current?.userId === user.id) {
|
|
166
|
+
debugLog('⚠️ USER CONNECTION SKIPPED', 'Already connected this user with this service');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const connectUser = async () => {
|
|
171
|
+
debugLog('🚀 STARTING USER CONNECTION', { userId: user.id });
|
|
172
|
+
connectingRef.current = true;
|
|
173
|
+
setIsLoading(true);
|
|
174
|
+
setError(null);
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
debugLog('📞 CALLING SERVICE.CONNECTUSER', { userId: user.id });
|
|
178
|
+
const streamClient = await service.connectUser(user);
|
|
179
|
+
setClient(streamClient);
|
|
180
|
+
setIsConnected(true);
|
|
181
|
+
connectedUserRef.current = { serviceId: service, userId: user.id }; // Mark as connected
|
|
182
|
+
debugLog('✅ USER CONNECTION SUCCESS', { userId: user.id, clientId: streamClient.userID });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
const errorMessage = err instanceof Error ? err.message : 'Connection failed';
|
|
185
|
+
setError(errorMessage);
|
|
186
|
+
debugLog('❌ USER CONNECTION ERROR', { userId: user.id, error: errorMessage });
|
|
187
|
+
} finally {
|
|
188
|
+
setIsLoading(false);
|
|
189
|
+
connectingRef.current = false;
|
|
190
|
+
debugLog('🔄 USER CONNECTION FINISHED', { userId: user.id, isConnected });
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
connectUser();
|
|
195
|
+
}, [service, user]); // Remove isConnected to prevent circular dependency
|
|
196
|
+
|
|
197
|
+
// Disconnect when user is removed (cleanup effect)
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
debugLog('🔌 CLEANUP EFFECT REGISTERED', { hasService: !!service, isConnected });
|
|
200
|
+
return () => {
|
|
201
|
+
if (service && isConnected) {
|
|
202
|
+
debugLog('🧹 CLEANUP EFFECT TRIGGERED', 'Cleaning up connection on unmount');
|
|
203
|
+
connectedUserRef.current = null; // Reset connection tracking
|
|
204
|
+
service.disconnectUser().catch(console.error);
|
|
205
|
+
} else {
|
|
206
|
+
debugLog('🔇 CLEANUP EFFECT SKIPPED', { hasService: !!service, isConnected });
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}, [service, isConnected]);
|
|
210
|
+
|
|
211
|
+
const refreshConnection = useCallback(async () => {
|
|
212
|
+
debugLog('🔄 REFRESH CONNECTION CALLED', { hasService: !!service, hasUser: !!user });
|
|
213
|
+
|
|
214
|
+
if (!service || !user) {
|
|
215
|
+
debugLog('⚠️ REFRESH CONNECTION SKIPPED', 'Missing service or user');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
debugLog('🚀 STARTING CONNECTION REFRESH', { userId: user.id });
|
|
220
|
+
setIsLoading(true);
|
|
221
|
+
try {
|
|
222
|
+
debugLog('🔌 DISCONNECTING FOR REFRESH');
|
|
223
|
+
await service.disconnectUser();
|
|
224
|
+
debugLog('📞 RECONNECTING FOR REFRESH');
|
|
225
|
+
const streamClient = await service.connectUser(user);
|
|
226
|
+
setClient(streamClient);
|
|
227
|
+
setIsConnected(true);
|
|
228
|
+
setError(null);
|
|
229
|
+
debugLog('✅ CONNECTION REFRESH SUCCESS', { userId: user.id });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const errorMessage = err instanceof Error ? err.message : 'Refresh failed';
|
|
232
|
+
setError(errorMessage);
|
|
233
|
+
debugLog('❌ CONNECTION REFRESH ERROR', { userId: user.id, error: errorMessage });
|
|
234
|
+
} finally {
|
|
235
|
+
setIsLoading(false);
|
|
236
|
+
debugLog('🔄 CONNECTION REFRESH FINISHED', { userId: user.id });
|
|
237
|
+
}
|
|
238
|
+
}, [service, user]);
|
|
239
|
+
|
|
240
|
+
// Memoize context value to prevent unnecessary re-renders
|
|
241
|
+
const contextValue: MessagingContextValue = React.useMemo(() => {
|
|
242
|
+
debugLog('💫 CONTEXT VALUE MEMOIZATION', {
|
|
243
|
+
hasService: !!service,
|
|
244
|
+
hasClient: !!client,
|
|
245
|
+
isConnected,
|
|
246
|
+
isLoading,
|
|
247
|
+
hasError: !!error,
|
|
248
|
+
capabilitiesKeys: Object.keys(capabilities),
|
|
249
|
+
customizationKeys: Object.keys(customization)
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
service,
|
|
254
|
+
client,
|
|
255
|
+
isConnected,
|
|
256
|
+
isLoading,
|
|
257
|
+
error,
|
|
258
|
+
capabilities,
|
|
259
|
+
customization,
|
|
260
|
+
refreshConnection,
|
|
261
|
+
debug,
|
|
262
|
+
};
|
|
263
|
+
}, [service, client, isConnected, isLoading, error, capabilities, customization, refreshConnection, debug]);
|
|
264
|
+
|
|
265
|
+
debugLog('🔄 RENDER END', {
|
|
266
|
+
renderCount: renderCountRef.current,
|
|
267
|
+
willRenderChat: !!(client && isConnected),
|
|
268
|
+
contextValueReady: !!contextValue
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<MessagingContext.Provider value={contextValue}>
|
|
273
|
+
{client && isConnected ? (
|
|
274
|
+
<Chat client={client}>
|
|
275
|
+
{children}
|
|
276
|
+
</Chat>
|
|
277
|
+
) : (
|
|
278
|
+
children
|
|
279
|
+
)}
|
|
280
|
+
</MessagingContext.Provider>
|
|
281
|
+
);
|
|
282
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { StreamChat } from 'stream-chat'
|
|
3
|
+
import { Chat } from 'stream-chat-react'
|
|
4
|
+
|
|
5
|
+
// Mock Stream Chat client for Storybook
|
|
6
|
+
const mockUser = {
|
|
7
|
+
id: 'storybook-user',
|
|
8
|
+
name: 'Storybook User',
|
|
9
|
+
image: 'https://i.pravatar.cc/150?img=1',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const createMockClient = () => {
|
|
13
|
+
const client = new StreamChat('mock-api-key', {
|
|
14
|
+
allowServerSideConnect: true,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Mock the client methods
|
|
18
|
+
client.connectUser = async () => {
|
|
19
|
+
return { me: mockUser } as any
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
client.disconnectUser = async () => {
|
|
23
|
+
return Promise.resolve()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// @ts-ignore
|
|
27
|
+
client.userID = mockUser.id
|
|
28
|
+
// @ts-ignore
|
|
29
|
+
client.user = mockUser
|
|
30
|
+
|
|
31
|
+
return client
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const MockChatProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
35
|
+
const [client] = React.useState(() => createMockClient())
|
|
36
|
+
|
|
37
|
+
React.useEffect(() => {
|
|
38
|
+
return () => {
|
|
39
|
+
client.disconnectUser().catch(console.error)
|
|
40
|
+
}
|
|
41
|
+
}, [client])
|
|
42
|
+
|
|
43
|
+
return <Chat client={client}>{children}</Chat>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Create a mock chat provider with pre-populated channels
|
|
47
|
+
export const MockChatProviderWithChannels: React.FC<{
|
|
48
|
+
children: React.ReactNode;
|
|
49
|
+
channelCount?: number;
|
|
50
|
+
}> = ({ children, channelCount = 3 }) => {
|
|
51
|
+
const [client] = React.useState(() => {
|
|
52
|
+
const mockClient = createMockClient()
|
|
53
|
+
|
|
54
|
+
// Create mock channels
|
|
55
|
+
const mockChannels = Array.from({ length: channelCount }, (_, i) => {
|
|
56
|
+
const participant = mockParticipants[i % mockParticipants.length]
|
|
57
|
+
|
|
58
|
+
const mockChannel = {
|
|
59
|
+
id: `channel-${i + 1}`,
|
|
60
|
+
cid: `messaging:channel-${i + 1}`,
|
|
61
|
+
type: 'messaging',
|
|
62
|
+
_client: mockClient,
|
|
63
|
+
state: {
|
|
64
|
+
members: {
|
|
65
|
+
[mockUser.id]: {
|
|
66
|
+
user: mockUser,
|
|
67
|
+
user_id: mockUser.id,
|
|
68
|
+
},
|
|
69
|
+
[participant.id]: {
|
|
70
|
+
user: participant,
|
|
71
|
+
user_id: participant.id,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
messages: [
|
|
75
|
+
{
|
|
76
|
+
id: `msg-${i}-1`,
|
|
77
|
+
text: `Hey! This is message ${i + 1}`,
|
|
78
|
+
created_at: new Date(Date.now() - 1000 * 60 * (i + 1)),
|
|
79
|
+
user: participant,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
unreadCount: i === 0 ? 2 : 0,
|
|
83
|
+
read: {},
|
|
84
|
+
},
|
|
85
|
+
// Mock channel methods
|
|
86
|
+
query: async () => ({ messages: [] }),
|
|
87
|
+
watch: async () => ({ messages: [] }),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return mockChannel
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// @ts-ignore - Mock queryChannels method
|
|
94
|
+
mockClient.queryChannels = async () => mockChannels
|
|
95
|
+
|
|
96
|
+
return mockClient
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
return () => {
|
|
101
|
+
client.disconnectUser().catch(console.error)
|
|
102
|
+
}
|
|
103
|
+
}, [client])
|
|
104
|
+
|
|
105
|
+
return <Chat client={client}>{children}</Chat>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Mock participants for testing
|
|
109
|
+
export const mockParticipants = [
|
|
110
|
+
{
|
|
111
|
+
id: 'participant-1',
|
|
112
|
+
name: 'Alice Johnson',
|
|
113
|
+
email: 'alice@example.com',
|
|
114
|
+
image: 'https://i.pravatar.cc/150?img=2',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'participant-2',
|
|
118
|
+
name: 'Bob Smith',
|
|
119
|
+
email: 'bob@example.com',
|
|
120
|
+
image: 'https://i.pravatar.cc/150?img=3',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'participant-3',
|
|
124
|
+
name: 'Carol Williams',
|
|
125
|
+
email: 'carol@example.com',
|
|
126
|
+
image: 'https://i.pravatar.cc/150?img=4',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'participant-4',
|
|
130
|
+
name: 'David Brown',
|
|
131
|
+
email: 'david@example.com',
|
|
132
|
+
image: 'https://i.pravatar.cc/150?img=5',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'participant-5',
|
|
136
|
+
name: 'Emma Davis',
|
|
137
|
+
email: 'emma@example.com',
|
|
138
|
+
image: 'https://i.pravatar.cc/150?img=6',
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
// Mock participant source for testing
|
|
143
|
+
export const mockParticipantSource = {
|
|
144
|
+
loadParticipants: async (options?: { search?: string; limit?: number }) => {
|
|
145
|
+
const searchTerm = options?.search || ''
|
|
146
|
+
const filtered = mockParticipants.filter(
|
|
147
|
+
(p) =>
|
|
148
|
+
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
149
|
+
p.email.toLowerCase().includes(searchTerm.toLowerCase())
|
|
150
|
+
)
|
|
151
|
+
return {
|
|
152
|
+
participants: filtered,
|
|
153
|
+
hasMore: false,
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
import { cleanup } from '@testing-library/react';
|
|
3
|
+
import { afterEach } from 'vitest';
|
|
4
|
+
|
|
5
|
+
// Cleanup after each test
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
cleanup();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Mock Stream Chat if needed
|
|
11
|
+
global.ResizeObserver = class ResizeObserver {
|
|
12
|
+
observe() {}
|
|
13
|
+
unobserve() {}
|
|
14
|
+
disconnect() {}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Mock IntersectionObserver for virtualization
|
|
18
|
+
global.IntersectionObserver = class IntersectionObserver {
|
|
19
|
+
constructor() {}
|
|
20
|
+
observe() {}
|
|
21
|
+
unobserve() {}
|
|
22
|
+
disconnect() {}
|
|
23
|
+
takeRecords() {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
root = null;
|
|
27
|
+
rootMargin = '';
|
|
28
|
+
thresholds = [];
|
|
29
|
+
};
|
|
30
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { render, RenderOptions, RenderResult } from '@testing-library/react';
|
|
2
|
+
import { ReactElement, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom render function that wraps components with common providers
|
|
6
|
+
*/
|
|
7
|
+
export function renderWithProviders(
|
|
8
|
+
ui: ReactElement,
|
|
9
|
+
options?: Omit<RenderOptions, 'wrapper'>
|
|
10
|
+
): RenderResult {
|
|
11
|
+
function Wrapper({ children }: { children: ReactNode }) {
|
|
12
|
+
return <>{children}</>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return render(ui, { wrapper: Wrapper, ...options });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Re-export everything from React Testing Library
|
|
20
|
+
*/
|
|
21
|
+
export * from '@testing-library/react';
|
|
22
|
+
export { default as userEvent } from '@testing-library/user-event';
|
|
23
|
+
|