@visns-studio/visns-components 6.16.6 → 6.17.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/README.md +148 -3
- package/package.json +1 -1
- package/src/components/sms/SmsClientConversations.jsx +439 -0
- package/src/components/sms/SmsComposeModal.jsx +37 -3
- package/src/components/sms/SmsInbox.jsx +43 -80
- package/src/components/sms/smsHelpers.js +111 -0
- package/src/components/sms/useSmsThreads.js +211 -0
- package/src/components/styles/Sms.module.scss +167 -0
- package/src/components/styles/global.css +33 -1
- package/src/index.js +12 -0
|
@@ -63,6 +63,14 @@ const SmsComposeModal = ({
|
|
|
63
63
|
lines = [],
|
|
64
64
|
status = null,
|
|
65
65
|
templates: templatesProp = null,
|
|
66
|
+
// Who this message is already for. A client page opening the composer
|
|
67
|
+
// knows the answer — `{number, id?, name?}` — and making somebody search
|
|
68
|
+
// for the client whose page they are standing on is the kind of small
|
|
69
|
+
// insult that stops a feature being used. With an `id` the recipient is
|
|
70
|
+
// set the way picking one out of the typeahead sets it, so the modal opens
|
|
71
|
+
// with the cursor's next stop being the message itself.
|
|
72
|
+
initialRecipient = null,
|
|
73
|
+
initialBody = '',
|
|
66
74
|
onCreated,
|
|
67
75
|
}) => {
|
|
68
76
|
const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
|
|
@@ -81,6 +89,16 @@ const SmsComposeModal = ({
|
|
|
81
89
|
const mountedRef = useRef(true);
|
|
82
90
|
const debouncedTo = useDebouncedValue(to, 250);
|
|
83
91
|
|
|
92
|
+
// Read through refs so that a consumer writing `initialRecipient={{...}}`
|
|
93
|
+
// inline — which is how every consumer will write it — does not re-seed
|
|
94
|
+
// the form on every render of the page behind the modal. The seed is
|
|
95
|
+
// applied once, when the modal opens.
|
|
96
|
+
const seedRef = useRef(initialRecipient);
|
|
97
|
+
seedRef.current = initialRecipient;
|
|
98
|
+
|
|
99
|
+
const seedBodyRef = useRef(initialBody);
|
|
100
|
+
seedBodyRef.current = initialBody;
|
|
101
|
+
|
|
84
102
|
useEffect(
|
|
85
103
|
() => () => {
|
|
86
104
|
mountedRef.current = false;
|
|
@@ -94,11 +112,27 @@ const SmsComposeModal = ({
|
|
|
94
112
|
if (!open) return;
|
|
95
113
|
|
|
96
114
|
setLineId(lines.length > 0 ? String(lines[0].id) : '');
|
|
97
|
-
setTo('');
|
|
98
|
-
setPicked(null);
|
|
99
|
-
setBody('');
|
|
100
115
|
setResults([]);
|
|
101
116
|
setError('');
|
|
117
|
+
|
|
118
|
+
// The seed goes on AFTER the reset, not instead of it: the reset is
|
|
119
|
+
// what makes a second open of the modal a clean one, and a seed
|
|
120
|
+
// applied first would be wiped by it.
|
|
121
|
+
const seed = seedRef.current;
|
|
122
|
+
const number = String(seed?.number ?? '').trim();
|
|
123
|
+
|
|
124
|
+
if (number !== '' && seed?.id !== undefined && seed?.id !== null) {
|
|
125
|
+
// Exactly the shape the typeahead produces, so a seeded recipient
|
|
126
|
+
// and a picked one are the same thing from here on — including the
|
|
127
|
+
// number being taken as given rather than re-judged.
|
|
128
|
+
setPicked({ id: seed.id, name: seed.name || number, number });
|
|
129
|
+
setTo('');
|
|
130
|
+
} else {
|
|
131
|
+
setPicked(null);
|
|
132
|
+
setTo(number);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
setBody(String(seedBodyRef.current ?? ''));
|
|
102
136
|
}, [open, lines]);
|
|
103
137
|
|
|
104
138
|
const typedNumber = looksLikeNumber(to);
|
|
@@ -17,6 +17,7 @@ import SmsComposeModal from './SmsComposeModal';
|
|
|
17
17
|
import SmsThreadPanel from './SmsThreadPanel';
|
|
18
18
|
import { resolveSmsEndpoints } from './smsEndpoints';
|
|
19
19
|
import useSmsLive, { defaultChannelFor } from './useSmsLive';
|
|
20
|
+
import useSmsThreads from './useSmsThreads';
|
|
20
21
|
import {
|
|
21
22
|
describeTransport,
|
|
22
23
|
initialsFor,
|
|
@@ -25,7 +26,6 @@ import {
|
|
|
25
26
|
relativeTime,
|
|
26
27
|
threadDisplayName,
|
|
27
28
|
threadIdFromSearch,
|
|
28
|
-
upsertThread,
|
|
29
29
|
} from './smsHelpers';
|
|
30
30
|
|
|
31
31
|
const PER_PAGE = 25;
|
|
@@ -119,11 +119,6 @@ const SmsInboxInner = ({
|
|
|
119
119
|
const [filter, setFilter] = useState('all');
|
|
120
120
|
const [search, setSearch] = useState('');
|
|
121
121
|
|
|
122
|
-
const [threads, setThreads] = useState([]);
|
|
123
|
-
const [page, setPage] = useState(1);
|
|
124
|
-
const [lastPage, setLastPage] = useState(1);
|
|
125
|
-
const [loading, setLoading] = useState(false);
|
|
126
|
-
|
|
127
122
|
const [selectedId, setSelectedId] = useState(() => threadIdFromSearch(locationSearch));
|
|
128
123
|
const [showConversation, setShowConversation] = useState(
|
|
129
124
|
() => Boolean(threadIdFromSearch(locationSearch))
|
|
@@ -134,11 +129,36 @@ const SmsInboxInner = ({
|
|
|
134
129
|
const [refreshToken, setRefreshToken] = useState(0);
|
|
135
130
|
|
|
136
131
|
const mountedRef = useRef(true);
|
|
137
|
-
const requestRef = useRef(0);
|
|
138
132
|
const seqRef = useRef(0);
|
|
139
133
|
|
|
140
134
|
const debouncedSearch = useDebouncedValue(search, 300);
|
|
141
135
|
|
|
136
|
+
// What this page adds to the shared list hook: which filters go with it.
|
|
137
|
+
// `null` means "not filtered" — the hook drops those before they reach the
|
|
138
|
+
// query string.
|
|
139
|
+
const threadParams = useMemo(
|
|
140
|
+
() => ({
|
|
141
|
+
line_id: lineId === 'all' ? null : lineId,
|
|
142
|
+
search: debouncedSearch,
|
|
143
|
+
unread_only: filter === 'unread' ? 1 : null,
|
|
144
|
+
archived: filter === 'archived' ? 1 : null,
|
|
145
|
+
}),
|
|
146
|
+
[lineId, debouncedSearch, filter]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// The list itself — paging, the request ticket, the live upsert — lives in
|
|
150
|
+
// useSmsThreads, which the client-page card shares.
|
|
151
|
+
const {
|
|
152
|
+
threads,
|
|
153
|
+
loading,
|
|
154
|
+
hasMore,
|
|
155
|
+
reload,
|
|
156
|
+
loadMore,
|
|
157
|
+
applyThread,
|
|
158
|
+
upsert,
|
|
159
|
+
clearUnread,
|
|
160
|
+
} = useSmsThreads({ endpoints, params: threadParams, perPage: PER_PAGE });
|
|
161
|
+
|
|
142
162
|
useEffect(
|
|
143
163
|
() => () => {
|
|
144
164
|
mountedRef.current = false;
|
|
@@ -177,64 +197,16 @@ const SmsInboxInner = ({
|
|
|
177
197
|
});
|
|
178
198
|
}, [routes]);
|
|
179
199
|
|
|
180
|
-
const fetchThreads = useCallback(
|
|
181
|
-
(targetPage = 1, { append = false } = {}) => {
|
|
182
|
-
const ticket = requestRef.current + 1;
|
|
183
|
-
|
|
184
|
-
requestRef.current = ticket;
|
|
185
|
-
setLoading(true);
|
|
186
|
-
|
|
187
|
-
const params = { page: targetPage, per_page: PER_PAGE };
|
|
188
|
-
|
|
189
|
-
if (lineId !== 'all') params.line_id = lineId;
|
|
190
|
-
if (debouncedSearch.trim() !== '') params.search = debouncedSearch.trim();
|
|
191
|
-
if (filter === 'unread') params.unread_only = 1;
|
|
192
|
-
if (filter === 'archived') params.archived = 1;
|
|
193
|
-
|
|
194
|
-
CustomFetch(
|
|
195
|
-
routes.threads,
|
|
196
|
-
'GET',
|
|
197
|
-
params,
|
|
198
|
-
(result) => {
|
|
199
|
-
if (!mountedRef.current || requestRef.current !== ticket) return;
|
|
200
|
-
|
|
201
|
-
const rows = Array.isArray(result?.data) ? result.data : [];
|
|
202
|
-
|
|
203
|
-
setThreads((prev) => (append ? [...prev, ...rows] : rows));
|
|
204
|
-
setPage(Number(result?.current_page) || targetPage);
|
|
205
|
-
setLastPage(Number(result?.last_page) || 1);
|
|
206
|
-
setLoading(false);
|
|
207
|
-
},
|
|
208
|
-
() => {
|
|
209
|
-
if (!mountedRef.current || requestRef.current !== ticket) return;
|
|
210
|
-
|
|
211
|
-
setLoading(false);
|
|
212
|
-
}
|
|
213
|
-
).catch(() => {
|
|
214
|
-
if (!mountedRef.current || requestRef.current !== ticket) return;
|
|
215
|
-
|
|
216
|
-
setLoading(false);
|
|
217
|
-
});
|
|
218
|
-
},
|
|
219
|
-
[routes, lineId, debouncedSearch, filter]
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
useEffect(() => {
|
|
223
|
-
fetchThreads(1);
|
|
224
|
-
}, [fetchThreads]);
|
|
225
|
-
|
|
226
200
|
/* ----------------------------------------------------------------- live */
|
|
227
201
|
|
|
228
202
|
const pushEvent = useCallback((event) => {
|
|
229
203
|
if (!mountedRef.current) return;
|
|
230
204
|
|
|
231
|
-
if (event?.thread)
|
|
232
|
-
setThreads((prev) => upsertThread(prev, event.thread));
|
|
233
|
-
}
|
|
205
|
+
if (event?.thread) upsert(event.thread);
|
|
234
206
|
|
|
235
207
|
seqRef.current += 1;
|
|
236
208
|
setLiveEvent({ seq: seqRef.current, payload: event });
|
|
237
|
-
}, []);
|
|
209
|
+
}, [upsert]);
|
|
238
210
|
|
|
239
211
|
const handleUnread = useCallback((payload) => {
|
|
240
212
|
if (!mountedRef.current) return;
|
|
@@ -253,9 +225,9 @@ const SmsInboxInner = ({
|
|
|
253
225
|
// Only ticks while there is no Echo instance: refresh the list, and
|
|
254
226
|
// let the conversation pane know to re-read itself.
|
|
255
227
|
onPoll: useCallback(() => {
|
|
256
|
-
|
|
228
|
+
reload();
|
|
257
229
|
setRefreshToken((prev) => prev + 1);
|
|
258
|
-
}, [
|
|
230
|
+
}, [reload]),
|
|
259
231
|
});
|
|
260
232
|
|
|
261
233
|
/* ------------------------------------------------------------ selection */
|
|
@@ -269,26 +241,17 @@ const SmsInboxInner = ({
|
|
|
269
241
|
|
|
270
242
|
// Optimistically clear the badge: the GET that opens the thread is
|
|
271
243
|
// what marks it read on the server, and it is already in flight.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
prev.map((one) =>
|
|
275
|
-
String(one.id) === String(id) ? { ...one, unread_count: 0 } : one
|
|
276
|
-
)
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
}, []);
|
|
244
|
+
clearUnread(id);
|
|
245
|
+
}, [clearUnread]);
|
|
280
246
|
|
|
281
|
-
const onThreadChange = useCallback(
|
|
282
|
-
|
|
247
|
+
const onThreadChange = useCallback(
|
|
248
|
+
(thread) => {
|
|
249
|
+
if (!mountedRef.current) return;
|
|
283
250
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
)
|
|
289
|
-
: upsertThread(prev, thread)
|
|
290
|
-
);
|
|
291
|
-
}, []);
|
|
251
|
+
applyThread(thread);
|
|
252
|
+
},
|
|
253
|
+
[applyThread]
|
|
254
|
+
);
|
|
292
255
|
|
|
293
256
|
/* --------------------------------------------------------------- render */
|
|
294
257
|
|
|
@@ -523,12 +486,12 @@ const SmsInboxInner = ({
|
|
|
523
486
|
)}
|
|
524
487
|
</ul>
|
|
525
488
|
|
|
526
|
-
{
|
|
489
|
+
{hasMore && (
|
|
527
490
|
<div className={styles.listFoot}>
|
|
528
491
|
<button
|
|
529
492
|
type="button"
|
|
530
493
|
className={styles.ghostButton}
|
|
531
|
-
onClick={
|
|
494
|
+
onClick={loadMore}
|
|
532
495
|
disabled={loading}
|
|
533
496
|
>
|
|
534
497
|
{loading ? (
|
|
@@ -583,7 +546,7 @@ const SmsInboxInner = ({
|
|
|
583
546
|
|
|
584
547
|
if (!thread) return;
|
|
585
548
|
|
|
586
|
-
|
|
549
|
+
upsert(thread);
|
|
587
550
|
selectThread(thread);
|
|
588
551
|
}}
|
|
589
552
|
/>
|
|
@@ -728,6 +728,113 @@ export const upsertMessage = (messages, message) => {
|
|
|
728
728
|
return next;
|
|
729
729
|
};
|
|
730
730
|
|
|
731
|
+
/**
|
|
732
|
+
* A client's conversations, newest first.
|
|
733
|
+
*
|
|
734
|
+
* The list endpoint already orders by `last_message_at` desc, so this is not
|
|
735
|
+
* about the first render - it is about what happens after one. A live event
|
|
736
|
+
* lands through `upsertThread`, which puts the thread it carries on top
|
|
737
|
+
* whatever its timestamp says, and an optimistic row added by the composer has
|
|
738
|
+
* no timestamp at all. On the inbox that is exactly right: the thing that just
|
|
739
|
+
* moved is the thing you want to see. On a client card showing two or three
|
|
740
|
+
* conversations, the rail flipping order because a delivery receipt arrived on
|
|
741
|
+
* the older one reads as a bug.
|
|
742
|
+
*
|
|
743
|
+
* A thread with no timestamp at all sorts to the top rather than the bottom:
|
|
744
|
+
* it is one that has just been created, and the person who created it is
|
|
745
|
+
* looking straight at it.
|
|
746
|
+
*/
|
|
747
|
+
export const orderThreadsByRecency = (threads) => {
|
|
748
|
+
const list = Array.isArray(threads) ? threads.filter(Boolean) : [];
|
|
749
|
+
|
|
750
|
+
const stampOf = (thread) => {
|
|
751
|
+
const date = parseDate(thread?.last_message?.at ?? thread?.updated_at ?? null);
|
|
752
|
+
|
|
753
|
+
return date === null ? null : date.getTime();
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
return list
|
|
757
|
+
.map((thread, index) => ({ thread, index, at: stampOf(thread) }))
|
|
758
|
+
.sort((a, b) => {
|
|
759
|
+
if (a.at === b.at) return a.index - b.index;
|
|
760
|
+
// Brand new, no message yet: top of the list.
|
|
761
|
+
if (a.at === null) return -1;
|
|
762
|
+
if (b.at === null) return 1;
|
|
763
|
+
|
|
764
|
+
return b.at - a.at;
|
|
765
|
+
})
|
|
766
|
+
.map((one) => one.thread);
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Which conversation a client card should open on.
|
|
771
|
+
*
|
|
772
|
+
* `preferredId` wins whenever it is still in the list - it is the thread the
|
|
773
|
+
* user picked, or the one a `?thread=` deep link named, and a refresh must not
|
|
774
|
+
* move them somewhere else. Otherwise: anything unread first (that is the
|
|
775
|
+
* reason to be on this tab), then the most recent.
|
|
776
|
+
*
|
|
777
|
+
* Returns null for an empty list, which is the card's empty state rather than
|
|
778
|
+
* an error - most clients have never been texted.
|
|
779
|
+
*/
|
|
780
|
+
export const pickInitialThread = (threads, preferredId = null) => {
|
|
781
|
+
const ordered = orderThreadsByRecency(threads);
|
|
782
|
+
|
|
783
|
+
if (ordered.length === 0) return null;
|
|
784
|
+
|
|
785
|
+
if (preferredId !== null && preferredId !== undefined && preferredId !== '') {
|
|
786
|
+
const preferred = ordered.find((one) => sameId(one.id, preferredId));
|
|
787
|
+
|
|
788
|
+
if (preferred) return preferred;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return ordered.find((one) => (Number(one.unread_count) || 0) > 0) ?? ordered[0];
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* The recipient a client card hands the compose modal.
|
|
796
|
+
*
|
|
797
|
+
* The CRM knows the client's mobiles; the composer would otherwise make
|
|
798
|
+
* somebody search for a client whose page they are already standing on. The
|
|
799
|
+
* first entry with a usable number wins - the host passes them in the order it
|
|
800
|
+
* wants them offered.
|
|
801
|
+
*
|
|
802
|
+
* Returns null when there is nothing to text, which is what turns the New
|
|
803
|
+
* message button off rather than opening a modal with an empty To field.
|
|
804
|
+
*/
|
|
805
|
+
export const clientRecipient = ({ id = null, name = '', numbers = [] } = {}) => {
|
|
806
|
+
const list = Array.isArray(numbers) ? numbers : [];
|
|
807
|
+
|
|
808
|
+
const usable = list.find((one) => {
|
|
809
|
+
const number = typeof one === 'string' ? one : one?.number;
|
|
810
|
+
|
|
811
|
+
return String(number ?? '').trim() !== '';
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
if (!usable) return null;
|
|
815
|
+
|
|
816
|
+
const number = String(
|
|
817
|
+
(typeof usable === 'string' ? usable : usable.number) ?? ''
|
|
818
|
+
).trim();
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
number,
|
|
822
|
+
// Without an id the modal falls back to a typed number, which is the
|
|
823
|
+
// right behaviour for a host that knows a mobile but not a client id.
|
|
824
|
+
id: id ?? null,
|
|
825
|
+
name: String(name ?? '').trim() || normaliseNumberForDisplay(number),
|
|
826
|
+
};
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
/** "No conversations" / "1 conversation" / "4 conversations". */
|
|
830
|
+
export const conversationCountLabel = (count) => {
|
|
831
|
+
const total = Number(count) || 0;
|
|
832
|
+
|
|
833
|
+
if (total <= 0) return 'No conversations';
|
|
834
|
+
|
|
835
|
+
return total === 1 ? '1 conversation' : `${total} conversations`;
|
|
836
|
+
};
|
|
837
|
+
|
|
731
838
|
/** Prepend a page of older messages, dropping any this timeline already has. */
|
|
732
839
|
export const prependMessages = (messages, older) => {
|
|
733
840
|
const list = Array.isArray(messages) ? messages : [];
|
|
@@ -1042,8 +1149,10 @@ export const describeRecipient = (input) => {
|
|
|
1042
1149
|
|
|
1043
1150
|
export default {
|
|
1044
1151
|
badgeCount,
|
|
1152
|
+
clientRecipient,
|
|
1045
1153
|
clockTime,
|
|
1046
1154
|
composerPlaceholder,
|
|
1155
|
+
conversationCountLabel,
|
|
1047
1156
|
counterLabel,
|
|
1048
1157
|
lineOptionLabel,
|
|
1049
1158
|
markRuns,
|
|
@@ -1058,7 +1167,9 @@ export default {
|
|
|
1058
1167
|
messageTimestamp,
|
|
1059
1168
|
nextPlaceholder,
|
|
1060
1169
|
normaliseNumberForDisplay,
|
|
1170
|
+
orderThreadsByRecency,
|
|
1061
1171
|
parseDate,
|
|
1172
|
+
pickInitialThread,
|
|
1062
1173
|
prependMessages,
|
|
1063
1174
|
RECIPIENT_HINTS,
|
|
1064
1175
|
relativeTime,
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import CustomFetch from '../Fetch';
|
|
4
|
+
import { resolveSmsEndpoints } from './smsEndpoints';
|
|
5
|
+
import { upsertThread } from './smsHelpers';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_PER_PAGE = 25;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Drop the keys the server should not see.
|
|
11
|
+
*
|
|
12
|
+
* A filter that is off must be absent rather than empty: `search=` and
|
|
13
|
+
* `line_id=` both mean "no filter" to this hook and neither should reach the
|
|
14
|
+
* query string, where an empty `client_id` in particular would be cast to 0 and
|
|
15
|
+
* match nothing.
|
|
16
|
+
*/
|
|
17
|
+
const cleanParams = (params) => {
|
|
18
|
+
const out = {};
|
|
19
|
+
|
|
20
|
+
Object.entries(params || {}).forEach(([key, value]) => {
|
|
21
|
+
if (value === null || value === undefined) return;
|
|
22
|
+
|
|
23
|
+
const trimmed = typeof value === 'string' ? value.trim() : value;
|
|
24
|
+
|
|
25
|
+
if (trimmed === '') return;
|
|
26
|
+
|
|
27
|
+
out[key] = trimmed;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return out;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A page of conversations, and the four things every surface does to one.
|
|
35
|
+
*
|
|
36
|
+
* Pulled out of SmsInbox when the client page grew its own conversation list:
|
|
37
|
+
* the two differ only in which filters they send — the inbox sends a line, a
|
|
38
|
+
* search and a chip; a client card sends `client_id` — and everything after
|
|
39
|
+
* the request is identical. Paging, the request ticket that stops a slow reply
|
|
40
|
+
* overwriting a fast one, the live upsert that moves a thread to the top, and
|
|
41
|
+
* the optimistic unread clear were all worth having once rather than twice.
|
|
42
|
+
*
|
|
43
|
+
* `params` may be a fresh object every render; the hook watches its CONTENTS,
|
|
44
|
+
* so an inline `{client_id: id}` does not refetch on every keystroke elsewhere
|
|
45
|
+
* on the page.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} options
|
|
48
|
+
* @param {object} [options.endpoints] Partial endpoints override.
|
|
49
|
+
* @param {object} [options.params] Query filters: `line_id`, `client_id`,
|
|
50
|
+
* `search`, `unread_only`, `archived`.
|
|
51
|
+
* @param {number} [options.perPage] Rows per request; default 25.
|
|
52
|
+
* @param {boolean} [options.enabled] False fetches nothing and keeps the
|
|
53
|
+
* list it already has.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{
|
|
56
|
+
* threads: Array, setThreads: Function, page: number, lastPage: number,
|
|
57
|
+
* loading: boolean, hasMore: boolean, loaded: boolean,
|
|
58
|
+
* reload: Function, loadMore: Function, applyThread: Function,
|
|
59
|
+
* upsert: Function, clearUnread: Function
|
|
60
|
+
* }}
|
|
61
|
+
*/
|
|
62
|
+
const useSmsThreads = ({
|
|
63
|
+
endpoints,
|
|
64
|
+
params = null,
|
|
65
|
+
perPage = DEFAULT_PER_PAGE,
|
|
66
|
+
enabled = true,
|
|
67
|
+
} = {}) => {
|
|
68
|
+
// The URL as a string, not the resolved object: a consumer writing
|
|
69
|
+
// `endpoints={{...}}` inline hands us a new object every render, and an
|
|
70
|
+
// effect keyed on that identity would refetch forever.
|
|
71
|
+
const threadsUrl = resolveSmsEndpoints(endpoints).threads;
|
|
72
|
+
|
|
73
|
+
const [threads, setThreads] = useState([]);
|
|
74
|
+
const [page, setPage] = useState(1);
|
|
75
|
+
const [lastPage, setLastPage] = useState(1);
|
|
76
|
+
const [loading, setLoading] = useState(false);
|
|
77
|
+
const [loaded, setLoaded] = useState(false);
|
|
78
|
+
|
|
79
|
+
const mountedRef = useRef(true);
|
|
80
|
+
// Every request takes a ticket; only the newest one is allowed to write.
|
|
81
|
+
// Without it, a slow "all lines" reply landing after a fast filtered one
|
|
82
|
+
// puts the wrong list on screen and nothing on the page looks wrong.
|
|
83
|
+
const requestRef = useRef(0);
|
|
84
|
+
|
|
85
|
+
useEffect(
|
|
86
|
+
() => () => {
|
|
87
|
+
mountedRef.current = false;
|
|
88
|
+
},
|
|
89
|
+
[]
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// The filters as one stable string, so the effect below re-runs when they
|
|
93
|
+
// actually change rather than when the caller rebuilt the object.
|
|
94
|
+
const paramsKey = JSON.stringify(
|
|
95
|
+
Object.entries(cleanParams(params)).sort(([a], [b]) => (a < b ? -1 : 1))
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const fetchPage = useCallback(
|
|
99
|
+
(targetPage = 1, { append = false } = {}) => {
|
|
100
|
+
if (!enabled) return;
|
|
101
|
+
|
|
102
|
+
const ticket = requestRef.current + 1;
|
|
103
|
+
|
|
104
|
+
requestRef.current = ticket;
|
|
105
|
+
setLoading(true);
|
|
106
|
+
|
|
107
|
+
const query = {
|
|
108
|
+
...Object.fromEntries(JSON.parse(paramsKey)),
|
|
109
|
+
page: targetPage,
|
|
110
|
+
per_page: perPage,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const settle = () => {
|
|
114
|
+
if (!mountedRef.current || requestRef.current !== ticket) return;
|
|
115
|
+
|
|
116
|
+
setLoading(false);
|
|
117
|
+
setLoaded(true);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
CustomFetch(
|
|
121
|
+
threadsUrl,
|
|
122
|
+
'GET',
|
|
123
|
+
query,
|
|
124
|
+
(result) => {
|
|
125
|
+
if (!mountedRef.current || requestRef.current !== ticket) return;
|
|
126
|
+
|
|
127
|
+
const rows = Array.isArray(result?.data) ? result.data : [];
|
|
128
|
+
|
|
129
|
+
setThreads((prev) => (append ? [...prev, ...rows] : rows));
|
|
130
|
+
setPage(Number(result?.current_page) || targetPage);
|
|
131
|
+
setLastPage(Number(result?.last_page) || 1);
|
|
132
|
+
settle();
|
|
133
|
+
},
|
|
134
|
+
settle
|
|
135
|
+
).catch(settle);
|
|
136
|
+
},
|
|
137
|
+
[threadsUrl, paramsKey, perPage, enabled]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
fetchPage(1);
|
|
142
|
+
}, [fetchPage]);
|
|
143
|
+
|
|
144
|
+
const reload = useCallback(() => fetchPage(1), [fetchPage]);
|
|
145
|
+
|
|
146
|
+
const loadMore = useCallback(() => {
|
|
147
|
+
if (loading || page >= lastPage) return;
|
|
148
|
+
|
|
149
|
+
fetchPage(page + 1, { append: true });
|
|
150
|
+
}, [fetchPage, loading, page, lastPage]);
|
|
151
|
+
|
|
152
|
+
/** A live event: the conversation that moved goes to the top. */
|
|
153
|
+
const upsert = useCallback((thread) => {
|
|
154
|
+
if (!thread) return;
|
|
155
|
+
|
|
156
|
+
setThreads((prev) => upsertThread(prev, thread));
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A thread we already know about has changed — merge it where it stands.
|
|
161
|
+
*
|
|
162
|
+
* Distinct from `upsert` on purpose: an open conversation writes back its
|
|
163
|
+
* own row constantly (read marks, a link, an archive), and reordering the
|
|
164
|
+
* list under the person reading it is not what any of those mean.
|
|
165
|
+
*/
|
|
166
|
+
const applyThread = useCallback((thread) => {
|
|
167
|
+
if (!thread) return;
|
|
168
|
+
|
|
169
|
+
setThreads((prev) =>
|
|
170
|
+
prev.some((one) => String(one.id) === String(thread.id))
|
|
171
|
+
? prev.map((one) =>
|
|
172
|
+
String(one.id) === String(thread.id) ? { ...one, ...thread } : one
|
|
173
|
+
)
|
|
174
|
+
: upsertThread(prev, thread)
|
|
175
|
+
);
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Zero a row's badge without waiting for the server.
|
|
180
|
+
*
|
|
181
|
+
* The GET that opens a conversation is what marks it read, and it is
|
|
182
|
+
* already in flight — leaving the badge lit until it answers makes the
|
|
183
|
+
* click look like it missed.
|
|
184
|
+
*/
|
|
185
|
+
const clearUnread = useCallback((threadId) => {
|
|
186
|
+
if (!threadId) return;
|
|
187
|
+
|
|
188
|
+
setThreads((prev) =>
|
|
189
|
+
prev.map((one) =>
|
|
190
|
+
String(one.id) === String(threadId) ? { ...one, unread_count: 0 } : one
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
}, []);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
threads,
|
|
197
|
+
setThreads,
|
|
198
|
+
page,
|
|
199
|
+
lastPage,
|
|
200
|
+
loading,
|
|
201
|
+
loaded,
|
|
202
|
+
hasMore: page < lastPage,
|
|
203
|
+
reload,
|
|
204
|
+
loadMore,
|
|
205
|
+
applyThread,
|
|
206
|
+
upsert,
|
|
207
|
+
clearUnread,
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export default useSmsThreads;
|