@visns-studio/visns-components 6.16.6 → 6.17.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.
@@ -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
- fetchThreads(1);
228
+ reload();
257
229
  setRefreshToken((prev) => prev + 1);
258
- }, [fetchThreads]),
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
- if (id) {
273
- setThreads((prev) =>
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((thread) => {
282
- if (!mountedRef.current || !thread) return;
247
+ const onThreadChange = useCallback(
248
+ (thread) => {
249
+ if (!mountedRef.current) return;
283
250
 
284
- setThreads((prev) =>
285
- prev.some((one) => String(one.id) === String(thread.id))
286
- ? prev.map((one) =>
287
- String(one.id) === String(thread.id) ? { ...one, ...thread } : one
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
- {page < lastPage && (
489
+ {hasMore && (
527
490
  <div className={styles.listFoot}>
528
491
  <button
529
492
  type="button"
530
493
  className={styles.ghostButton}
531
- onClick={() => fetchThreads(page + 1, { append: true })}
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
- setThreads((prev) => upsertThread(prev, thread));
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;