@linktr.ee/messaging-react 1.38.0 → 1.39.0

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.
@@ -1,262 +0,0 @@
1
- import { ChatCircleDotsIcon, SpinnerGapIcon } from '@phosphor-icons/react'
2
- import classNames from 'classnames'
3
- import React, { useCallback, useEffect, useState, useRef } from 'react'
4
-
5
- import { useMessagingContext } from '../../providers/MessagingProvider'
6
- import type { ParticipantPickerProps, Participant } from '../../types'
7
- import { Avatar } from '../Avatar'
8
- import { CloseButton } from '../CloseButton'
9
- import { SearchInput } from '../SearchInput'
10
-
11
- /**
12
- * Generic participant picker component for starting conversations
13
- */
14
- export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
15
- participantSource,
16
- onSelectParticipant,
17
- onClose,
18
- existingParticipantIds = new Set(),
19
- participantLabel = 'participants',
20
- searchPlaceholder = 'Search participants...',
21
- className,
22
- }) => {
23
- const { debug } = useMessagingContext()
24
- const [searchQuery, setSearchQuery] = useState('')
25
- const [participants, setParticipants] = useState<Participant[]>([])
26
- const [loading, setLoading] = useState(false)
27
- const [error, setError] = useState<string | null>(null)
28
- const [startingChatWithId, setStartingChatWithId] = useState<string | null>(
29
- null
30
- )
31
-
32
- // Track if we've already loaded participants to prevent repeated loading
33
- const loadedRef = useRef(false)
34
-
35
- // New source instance should be allowed to load again
36
- useEffect(() => {
37
- loadedRef.current = false
38
- }, [participantSource])
39
-
40
- /* eslint-disable react-hooks/exhaustive-deps -- syncs with participantSource + debug; inner async uses participantSource.loadParticipants */
41
- // Load participants initially - wait for participantSource to finish loading first
42
- useEffect(() => {
43
- // Wait for the participantSource to finish loading before we try to load participants
44
- if (participantSource.loading) {
45
- if (debug) {
46
- console.log(
47
- '[ParticipantPicker] Waiting for participant source to finish loading...'
48
- )
49
- }
50
- return
51
- }
52
-
53
- if (loadedRef.current) return // Prevent multiple loads
54
-
55
- const loadInitialParticipants = async () => {
56
- if (debug) {
57
- console.log('[ParticipantPicker] Loading initial participants...')
58
- }
59
- setLoading(true)
60
- setError(null)
61
-
62
- try {
63
- const result = await participantSource.loadParticipants({
64
- search: '', // Load all participants initially
65
- limit: 100,
66
- })
67
- setParticipants(result.participants)
68
- loadedRef.current = true // Mark as loaded
69
- if (debug) {
70
- console.log(
71
- '[ParticipantPicker] Participants loaded successfully:',
72
- result.participants.length
73
- )
74
- }
75
- } catch (err) {
76
- const errorMessage =
77
- err instanceof Error ? err.message : 'Failed to load participants'
78
- setError(errorMessage)
79
- console.error('[ParticipantPicker] Failed to load participants:', err)
80
- // Don't mark as loaded on error, allow retry
81
- } finally {
82
- setLoading(false)
83
- }
84
- }
85
-
86
- loadInitialParticipants()
87
- }, [participantSource, debug])
88
- /* eslint-enable react-hooks/exhaustive-deps */
89
-
90
- // Filter participants by search query and existing participants
91
- const availableParticipants = participants
92
- .filter((participant) => !existingParticipantIds.has(participant.id))
93
- .filter((participant) => {
94
- if (!searchQuery) return true
95
- const searchLower = searchQuery.toLowerCase()
96
- return (
97
- participant.name.toLowerCase().includes(searchLower) ||
98
- participant.email?.toLowerCase().includes(searchLower) ||
99
- false
100
- )
101
- })
102
-
103
- const handleSelectParticipant = useCallback(
104
- async (participant: Participant) => {
105
- if (startingChatWithId) return // Prevent multiple clicks
106
-
107
- setStartingChatWithId(participant.id)
108
- try {
109
- await onSelectParticipant(participant)
110
- } catch (error) {
111
- console.error('[ParticipantPicker] Failed to start chat:', error)
112
- // Reset the loading state on error
113
- setStartingChatWithId(null)
114
- }
115
- // Note: Don't reset startingChatWithId on success because the dialog will close
116
- },
117
- [onSelectParticipant, startingChatWithId]
118
- )
119
-
120
- const handleKeyDown = (
121
- event: React.KeyboardEvent,
122
- participant: Participant
123
- ) => {
124
- if (event.key === 'Enter' || event.key === ' ') {
125
- event.preventDefault()
126
- handleSelectParticipant(participant)
127
- }
128
- }
129
-
130
- return (
131
- <div className={classNames('flex flex-col h-full', className)}>
132
- {/* Header */}
133
- <div className="px-4 py-4 border-b border-sand bg-chalk">
134
- <div className="flex items-center justify-between mb-3">
135
- <h2 className="text-lg font-semibold text-charcoal">
136
- Start a new Conversation
137
- </h2>
138
- <CloseButton onClick={onClose} />
139
- </div>
140
-
141
- <p className="text-xs text-stone mb-3">
142
- Select a {participantLabel.slice(0, -1)} to start messaging (
143
- {availableParticipants.length} available)
144
- {participantSource.totalCount !== undefined &&
145
- ` • ${participantSource.totalCount} ${participantLabel} total`}
146
- </p>
147
-
148
- <SearchInput
149
- searchQuery={searchQuery}
150
- setSearchQuery={setSearchQuery}
151
- placeholder={searchPlaceholder}
152
- />
153
- </div>
154
-
155
- {/* Error State */}
156
- {error && (
157
- <div className="p-4 text-sm text-danger bg-danger-alt">
158
- Error loading {participantLabel}: {error}
159
- </div>
160
- )}
161
-
162
- {/* Participants List */}
163
- <div className="flex-1 overflow-auto">
164
- {loading && availableParticipants.length === 0 ? (
165
- <div className="h-32 flex items-center justify-center">
166
- <div className="flex items-center space-x-2">
167
- <div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
168
- <span className="text-sm text-stone">
169
- Loading {participantLabel}...
170
- </span>
171
- </div>
172
- </div>
173
- ) : availableParticipants.length === 0 ? (
174
- <div className="p-6 text-center">
175
- <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-sand">
176
- <ChatCircleDotsIcon className="h-8 w-8 text-charcoal" />
177
- </div>
178
- <h3 className="text-sm font-semibold text-charcoal mb-2">
179
- {searchQuery
180
- ? `No ${participantLabel} found`
181
- : participants.length > 0
182
- ? `Already chatting with all ${participantLabel}`
183
- : `No ${participantLabel} yet`}
184
- </h3>
185
- <p className="text-xs text-stone">
186
- {searchQuery
187
- ? 'Try a different search term'
188
- : participants.length > 0
189
- ? `You have existing conversations with all your ${participantLabel}`
190
- : `${participantLabel.charAt(0).toUpperCase() + participantLabel.slice(1)} will appear here`}
191
- </p>
192
- </div>
193
- ) : (
194
- <ul className="space-y-0">
195
- {availableParticipants.map((participant) => {
196
- const displayName =
197
- participant.name || participant.email || participant.id
198
- const displaySecondary =
199
- participant.email && participant.name
200
- ? participant.email
201
- : participant.phone
202
-
203
- return (
204
- <li key={participant.id}>
205
- <button
206
- type="button"
207
- onClick={() => handleSelectParticipant(participant)}
208
- onKeyDown={(e) => handleKeyDown(e, participant)}
209
- className="w-full px-4 py-3 hover:bg-sand transition-colors border-b border-sand text-left focus-ring"
210
- >
211
- <div className="flex items-center justify-between">
212
- <div className="flex items-center space-x-3 flex-1 min-w-0">
213
- {/* Avatar */}
214
- <Avatar
215
- id={participant.id}
216
- name={displayName}
217
- image={participant.image}
218
- size={40}
219
- />
220
-
221
- {/* Info */}
222
- <div className="flex-1 min-w-0">
223
- <h4 className="text-sm font-medium text-charcoal truncate">
224
- {displayName}
225
- </h4>
226
- {displaySecondary && (
227
- <p className="text-xs text-stone truncate">
228
- {displaySecondary}
229
- </p>
230
- )}
231
- </div>
232
- </div>
233
-
234
- {/* Icon */}
235
- <div className="flex-shrink-0">
236
- {startingChatWithId === participant.id ? (
237
- <SpinnerGapIcon className="h-5 w-5 text-primary animate-spin" />
238
- ) : (
239
- <ChatCircleDotsIcon className="h-5 w-5 text-stone" />
240
- )}
241
- </div>
242
- </div>
243
- </button>
244
- </li>
245
- )
246
- })}
247
-
248
- {/* Loading indicator */}
249
- {loading && (
250
- <li className="p-4 flex justify-center">
251
- <div className="flex items-center space-x-2">
252
- <div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
253
- <span className="text-sm text-stone">Loading more...</span>
254
- </div>
255
- </li>
256
- )}
257
- </ul>
258
- )}
259
- </div>
260
- </div>
261
- )
262
- }
@@ -1,94 +0,0 @@
1
- import { useState, useEffect, useCallback } from 'react';
2
-
3
- import type { ParticipantSource, Participant } from '../types';
4
-
5
- /**
6
- * Hook for managing participant loading with search and pagination
7
- */
8
- export const useParticipants = (
9
- participantSource: ParticipantSource,
10
- options: {
11
- initialSearch?: string;
12
- pageSize?: number;
13
- } = {}
14
- ) => {
15
- const { initialSearch = '', pageSize = 20 } = options;
16
-
17
- const [participants, setParticipants] = useState<Participant[]>([]);
18
- const [loading, setLoading] = useState(false);
19
- const [error, setError] = useState<string | null>(null);
20
- const [searchQuery, setSearchQuery] = useState(initialSearch);
21
- const [hasMore, setHasMore] = useState(true);
22
- const [cursor, setCursor] = useState<string | undefined>();
23
-
24
- // Load participants with current search query
25
- const loadParticipants = useCallback(async (
26
- reset = false,
27
- customSearch?: string
28
- ) => {
29
- if (loading) return;
30
-
31
- const search = customSearch !== undefined ? customSearch : searchQuery;
32
-
33
- setLoading(true);
34
- setError(null);
35
-
36
- try {
37
- const result = await participantSource.loadParticipants({
38
- search: search || undefined,
39
- limit: pageSize,
40
- cursor: reset ? undefined : cursor,
41
- });
42
-
43
- setParticipants(prev =>
44
- reset ? result.participants : [...prev, ...result.participants]
45
- );
46
- setHasMore(result.hasMore);
47
- setCursor(result.nextCursor);
48
- } catch (err) {
49
- const errorMessage = err instanceof Error ? err.message : 'Failed to load participants';
50
- setError(errorMessage);
51
- console.error('[useParticipants] Load error:', err);
52
- } finally {
53
- setLoading(false);
54
- }
55
- }, [participantSource, searchQuery, cursor, pageSize, loading]);
56
-
57
- // Load more participants (pagination)
58
- const loadMore = useCallback(() => {
59
- if (hasMore && !loading) {
60
- loadParticipants(false);
61
- }
62
- }, [hasMore, loading, loadParticipants]);
63
-
64
- // Search participants
65
- const search = useCallback((query: string) => {
66
- setSearchQuery(query);
67
- setCursor(undefined);
68
- loadParticipants(true, query);
69
- }, [loadParticipants]);
70
-
71
- // Refresh participants
72
- const refresh = useCallback(() => {
73
- setCursor(undefined);
74
- loadParticipants(true);
75
- }, [loadParticipants]);
76
-
77
- /* eslint-disable react-hooks/exhaustive-deps -- initial load only; `loadParticipants` changes whenever `loading` flips */
78
- useEffect(() => {
79
- loadParticipants(true);
80
- }, [participantSource.loadParticipants]);
81
- /* eslint-enable react-hooks/exhaustive-deps */
82
-
83
- return {
84
- participants,
85
- loading,
86
- error,
87
- searchQuery,
88
- hasMore,
89
- totalCount: participantSource.totalCount,
90
- loadMore,
91
- search,
92
- refresh,
93
- };
94
- };