@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.
package/README.md CHANGED
@@ -9,6 +9,62 @@ A comprehensive React component library used by the VISNS Studio team for CRM an
9
9
 
10
10
  VISNS Components is a React-based UI component library that provides a set of reusable, consistent, and customizable components for building web applications. It includes components for authentication, data grids, forms, navigation, and more, designed to work seamlessly together.
11
11
 
12
+ ## Recent Updates (v6.17.0)
13
+
14
+ ### A client's text messages, on the client's own page
15
+
16
+ `SmsClientConversations` is the messaging module as a card: one client's
17
+ conversations, for the "SMS" tab of their detail page. It answers the question
18
+ an adviser opening a client actually has — *what have we said to this person* —
19
+ which the inbox could only answer by being searched for a name the adviser was
20
+ already looking at.
21
+
22
+ ```jsx
23
+ <SmsClientConversations
24
+ clientId={client.id}
25
+ clientName={client.name}
26
+ numbers={[{ label: 'Mobile', number: client.mobile }]}
27
+ navigate={navigate}
28
+ echo={getEcho}
29
+ />
30
+ ```
31
+
32
+ It is the inbox's own parts, filtered — `GET {base}/threads?client_id=` (the
33
+ `client_id` filter is new in `visnsstudio/visns-packages`, after 4.6.0),
34
+ `SmsThreadPanel` for the conversation, `useSmsLive`
35
+ across every line the user can work — with three deliberate differences:
36
+
37
+ - **The rail is named after the line, not the client.** Every row is the same
38
+ person, so their name on each one carries nothing; which of the practice's
39
+ numbers they are talking to is the thing that differs. The avatar column goes
40
+ with it.
41
+ - **A live event is admitted only if it is about this client.** The
42
+ subscription covers whole lines, so most of what arrives belongs to somebody
43
+ else's page. It still reaches the conversation pane, which matches on thread
44
+ id; it simply never joins this rail.
45
+ - **New message needs no search.** The host passes the client's mobiles in and
46
+ the composer opens with the recipient already picked — `SmsComposeModal` took
47
+ `initialRecipient` (`{number, id?, name?}`) and `initialBody` for it. Making
48
+ somebody search for the client whose page they are standing on is the kind of
49
+ small insult that stops a feature being used. No mobile on the record leaves
50
+ the button disabled and saying why, rather than opening a composer with an
51
+ empty To field.
52
+
53
+ The card is a fixed-height box (`height`, 560px by default) with the
54
+ conversation scrolling inside it: a timeline that grows the page pushes the
55
+ composer off the bottom of the screen, and on a client page it would take the
56
+ rest of the tab with it.
57
+
58
+ ### `useSmsThreads`
59
+
60
+ The thread list — paging, the request ticket that stops a slow reply
61
+ overwriting a fast one, the live upsert that moves a conversation to the top,
62
+ the optimistic unread clear — came out of `SmsInbox` into a hook the client
63
+ card shares. The two surfaces now differ only in which filters they send.
64
+ `orderThreadsByRecency`, `pickInitialThread`, `clientRecipient` and
65
+ `conversationCountLabel` are exported alongside it, all covered by
66
+ `tests/sms.test.mjs`.
67
+
12
68
  ## Recent Updates (v6.15.0)
13
69
 
14
70
  ### The detail page joins the dashboard system
@@ -283,11 +339,13 @@ connected* rather than as an error, because it is not one.
283
339
  | `SmsInboxBadge` | The header popover. Mounts as an account action through Navigation's `renderers` slot. |
284
340
  | `SmsInbox` | The page: line selector, search, filters, thread list, conversation, composer. |
285
341
  | `SmsThreadPanel` | One conversation on its own — header, timeline, composer — for embedding on a client page. |
342
+ | `SmsClientConversations` | One client's conversations, as a card for their detail page: rail, conversation, seeded composer. |
286
343
  | `SmsComposeModal` | New message: line, a number or a client typeahead, templates, send. |
287
344
  | `SmsLineSettings` | Settings → Messaging: status card, lines table, templates. |
288
345
  | `useSmsLive` | Echo subscription per line, with a 30-second polling fallback when there is no Echo. |
346
+ | `useSmsThreads` | The thread list behind `SmsInbox` and `SmsClientConversations`: filters, paging, live upsert. |
289
347
  | `makeSmsEndpoints`, `DEFAULT_SMS_ENDPOINTS`, `resolveSmsEndpoints` | The URL table. |
290
- | `segmentCount`, `normaliseNumberForDisplay`, `looksLikeNumber`, `toE164`, `relativeTime`, `groupMessagesByDay`, `initialsFor`, `threadDisplayName`, `describeTransport`, `isHeldTransport`, `SMS_LIMITS`, `SMS_MAX_SEGMENTS`, `SMS_STATUS_LABELS` | Pure helpers, all covered by `tests/sms.test.mjs`. |
348
+ | `segmentCount`, `normaliseNumberForDisplay`, `looksLikeNumber`, `toE164`, `relativeTime`, `groupMessagesByDay`, `initialsFor`, `threadDisplayName`, `describeTransport`, `isHeldTransport`, `orderThreadsByRecency`, `pickInitialThread`, `clientRecipient`, `conversationCountLabel`, `SMS_LIMITS`, `SMS_MAX_SEGMENTS`, `SMS_STATUS_LABELS` | Pure helpers, all covered by `tests/sms.test.mjs`. |
291
349
 
292
350
  #### `SmsInboxBadge`
293
351
 
@@ -393,6 +451,91 @@ broadcast arriving first cannot produce a duplicate. Enter sends by default
393
451
  because "70" with no explanation looks like a bug: one curly quote or emoji
394
452
  drops the message from GSM-7 to UCS-2 and the limit with it.
395
453
 
454
+ #### `SmsClientConversations`
455
+
456
+ One client's messages, as a card for the "SMS" tab of their detail page.
457
+
458
+ | Prop | Default | Notes |
459
+ | --- | --- | --- |
460
+ | `clientId` | — | **Required.** Sent as `client_id`; nothing is fetched until it is set, so a card mounted before the record loads does not ask for every thread in the practice. |
461
+ | `clientName` | `''` | Named in the empty state, and the name the composer's recipient chip shows. |
462
+ | `numbers` | `[]` | `[{label, number}]` — the client's mobiles, in the order they should be offered. The first usable one seeds **New message**; none disables it. Bare strings work too. |
463
+ | `endpoints` | `DEFAULT_SMS_ENDPOINTS` | Merged over the defaults. |
464
+ | `echo` | `null` | Instance or factory. **The card owns the subscription** (every line the user can work) and feeds the conversation pane, which is mounted `subscribe={false}`. |
465
+ | `channelFor` | `` (id) => `sms-line.${id}` `` | |
466
+ | `userProfile` | `null` | Accepted for consistency; unread. |
467
+ | `inboxUrl` | `'/sms'` | "Open in Messages" links here with `?thread=<selected>`. |
468
+ | `navigate` | `null` | Given, the link stays inside the SPA; without it it is an ordinary anchor and a full page load. Either way it is a real `href` — middle-clickable and copyable. |
469
+ | `lines` | `null` | `[{id, label, phone_number}]`. Fetched from `{base}/lines` when not supplied. Names the rows in the rail. |
470
+ | `status` | `null` | The `/status` payload. Fetched here when not supplied. |
471
+ | `templates` | `null` | Passed to the conversation and the composer, both of which fetch their own when it is null. |
472
+ | `canManage` | `false` | Gates the conversation's "Simulate reply". |
473
+ | `clientUrl` | `` (id) => `/clients/${id}` `` | |
474
+ | `title` | `'Messages'` | The card heading. |
475
+ | `height` | `560` | The panes' height. A number is pixels; a string is passed through. |
476
+ | `className` | `''` | Added to the card. |
477
+
478
+ The rail lists this client's conversations — **line label · number · last
479
+ message · unread badge** — and the panel is the full `SmsThreadPanel`:
480
+ templates, the segment counter, Enter-sends, live updates. Most clients have
481
+ exactly one conversation; the rail earns its place by saying which of the
482
+ practice's numbers it is on. Below 780px it becomes a strip above the
483
+ conversation rather than a column beside it.
484
+
485
+ With no conversations at all the card says *No text messages with {clientName}
486
+ yet* and offers the same New message button — which is the state most clients
487
+ are in, so it has to look deliberate rather than broken.
488
+
489
+ ##### Wiring a client's SMS tab
490
+
491
+ ```jsx
492
+ import { SmsClientConversations } from '@visns-studio/visns-components';
493
+ import { useNavigate } from 'react-router-dom';
494
+
495
+ const ClientSmsTab = ({ client }) => {
496
+ const navigate = useNavigate();
497
+
498
+ return (
499
+ <SmsClientConversations
500
+ clientId={client.id}
501
+ clientName={client.name}
502
+ numbers={[
503
+ { label: 'Mobile', number: client.mobile },
504
+ { label: 'Other', number: client.mobile_2 },
505
+ ]}
506
+ endpoints={{ base: '/ajax/sms' }}
507
+ echo={getEcho}
508
+ channelFor={(id) => `sms-line.${id}.${import.meta.env.VITE_PUSHER_ENV}`}
509
+ navigate={navigate}
510
+ inboxUrl="/messages"
511
+ canManage={can('Messaging Manage')}
512
+ height={620}
513
+ />
514
+ );
515
+ };
516
+ ```
517
+
518
+ The backend needs nothing beyond a `visnsstudio/visns-packages` new enough for
519
+ `GET {base}/threads` to take `client_id` (added after 4.6.0). It filters **on top of** line
520
+ visibility, not around it: a conversation with the same client on a line this
521
+ user is not attached to stays invisible, so a client page cannot become a way
522
+ around the line pivot. Threads are linked to a client by the
523
+ `messaging.client_resolver` hook when they are created, or by hand from the
524
+ conversation header — an unlinked thread will not appear here.
525
+
526
+ #### `SmsComposeModal`
527
+
528
+ | Prop | Default | Notes |
529
+ | --- | --- | --- |
530
+ | `open`, `onClose` | — | |
531
+ | `endpoints` | `DEFAULT_SMS_ENDPOINTS` | |
532
+ | `lines` | `[]` | One line is stated rather than offered as a choice. |
533
+ | `status` | `null` | Drives the "not connected" banner. |
534
+ | `templates` | `null` | Fetched lazily on first opening the menu. |
535
+ | `initialRecipient` | `null` | `{number, id?, name?}`. With an `id` the recipient is set exactly as picking one out of the typeahead sets it — including the number being taken as given rather than re-judged — so the modal opens with the message box as the next stop. Without one it pre-fills the To field. Applied **after** the open-reset, once per opening. |
536
+ | `initialBody` | `''` | Pre-fills the message. |
537
+ | `onCreated` | — | `(thread, message)` — the 201 payload. |
538
+
396
539
  #### `SmsLineSettings`
397
540
 
398
541
  | Prop | Default | Notes |
@@ -412,7 +555,7 @@ a partial `endpoints` object merged over it. Session + CSRF come from
412
555
  GET {base}/status -> {transport, connected, lines_count, unread_total}
413
556
  GET {base}/lines -> {data: [{id, label, phone_number, display_number, active, unread_count}]}
414
557
  GET {base}/unread -> {total, by_line, by_thread}
415
- GET {base}/threads?line_id&search&unread_only&archived&page&per_page
558
+ GET {base}/threads?line_id&client_id&search&unread_only&archived&page&per_page
416
559
  POST {base}/threads {line_id, to, body?} -> 201 {thread, message|null}
417
560
  GET {base}/threads/{id}?before&limit -> {thread, messages, has_more} (marks read)
418
561
  PUT {base}/threads/{id} {client_id, client_name, contact_name}
@@ -460,7 +603,9 @@ and `.sms.updated`, both carrying `{thread, message}`.
460
603
 
461
604
  `yarn dev:sms` serves `dev/sms-fixture.html` on port 5181 against an in-memory
462
605
  backend (`dev/smsMockServer.js`): three lines, twelve conversations, mixed
463
- statuses, one thread deep enough to page. The transport switch in the fixture
606
+ statuses, one thread deep enough to page. Three tabs — the inbox, a client
607
+ page's SMS tab (`SmsClientConversations` for the client two of those
608
+ conversations belong to), and Settings → Messaging. The transport switch in the fixture
464
609
  header is the point of it — on `null` a sent message stays *Held*, on `log` it
465
610
  reaches *Sent*, on `zoom` it reaches *Delivered*. The mock also exports a fake
466
611
  Echo, so "Simulate an inbound" pushes a real broadcast and the badge, the list
package/package.json CHANGED
@@ -95,7 +95,7 @@
95
95
  "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
96
96
  },
97
97
  "name": "@visns-studio/visns-components",
98
- "version": "6.16.6",
98
+ "version": "6.17.0",
99
99
  "description": "Various packages to assist in the development of our Custom Applications.",
100
100
  "main": "src/index.js",
101
101
  "files": [
@@ -0,0 +1,439 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ExternalLink, MessageSquare, PenSquare } from 'lucide-react';
3
+
4
+ import CustomFetch from '../Fetch';
5
+ import styles from '../styles/Sms.module.scss';
6
+ import SmsComposeModal from './SmsComposeModal';
7
+ import SmsThreadPanel from './SmsThreadPanel';
8
+ import { resolveSmsEndpoints } from './smsEndpoints';
9
+ import useSmsLive, { defaultChannelFor } from './useSmsLive';
10
+ import useSmsThreads from './useSmsThreads';
11
+ import {
12
+ clientRecipient,
13
+ conversationCountLabel,
14
+ lineNumberForDisplay,
15
+ orderThreadsByRecency,
16
+ pickInitialThread,
17
+ relativeTime,
18
+ threadSubtitle,
19
+ } from './smsHelpers';
20
+
21
+ /** Enough rows for any client; a rail is not a place anybody pages through. */
22
+ const PER_PAGE = 50;
23
+
24
+ const previewOf = (thread) => {
25
+ const message = thread?.last_message;
26
+
27
+ if (!message || !message.body) return 'No messages yet';
28
+
29
+ const body = String(message.body).replace(/\s+/g, ' ').trim();
30
+
31
+ return message.direction === 'out' ? `You: ${body}` : body;
32
+ };
33
+
34
+ /**
35
+ * One client's text messages, as a card for their detail page.
36
+ *
37
+ * The inbox answers "what is the practice talking about"; this answers "what
38
+ * have we said to this person" — the question an adviser opening a client
39
+ * actually has, and one the inbox can only answer by being searched for a name
40
+ * the adviser is already looking at.
41
+ *
42
+ * It is the inbox's own parts, filtered: `useSmsThreads` with `client_id`,
43
+ * `SmsThreadPanel` for the conversation, `useSmsLive` across every line the
44
+ * user can work. Three things are deliberately different:
45
+ *
46
+ * - **The rail is named after the LINE, not the client.** Every row here is
47
+ * the same person, so their name on each one carries nothing; which of the
48
+ * practice's numbers they are talking to is the thing that differs.
49
+ * - **A live event is admitted only if it is about this client.** The
50
+ * subscription covers whole lines, so most of what arrives belongs to
51
+ * somebody else's page.
52
+ * - **New message needs no search.** The host knows the client's mobiles and
53
+ * passes them in; the composer opens with the recipient already set.
54
+ */
55
+ const SmsClientConversations = ({
56
+ clientId,
57
+ clientName = '',
58
+ // `[{label, number}]` — the mobiles the CRM holds for this client, in the
59
+ // order they should be offered. The first usable one is what New message
60
+ // opens with.
61
+ numbers = [],
62
+ endpoints,
63
+ echo = null,
64
+ channelFor = defaultChannelFor,
65
+ // Taken so the card mounts with the same prop bag as every other CRM
66
+ // surface, though the layout does not read it.
67
+ userProfile: _userProfile = null,
68
+ inboxUrl = '/sms',
69
+ // Given, a click stays inside the SPA; without it the link is an ordinary
70
+ // one and a full page load, which is right for a fixture or a Blade page.
71
+ navigate = null,
72
+ lines: linesProp = null,
73
+ status: statusProp = null,
74
+ templates: templatesProp = null,
75
+ canManage = false,
76
+ clientUrl = (id) => `/clients/${id}`,
77
+ title = 'Messages',
78
+ // The card is a fixed-height box with the conversation scrolling inside
79
+ // it: a timeline that grows the page pushes the composer off the bottom of
80
+ // the screen, and on a client page it would push the rest of the tab with
81
+ // it. A number is read as pixels; a string is passed through.
82
+ height = 560,
83
+ className = '',
84
+ }) => {
85
+ const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
86
+
87
+ const [ownLines, setOwnLines] = useState(null);
88
+ const [ownStatus, setOwnStatus] = useState(null);
89
+
90
+ const [selectedId, setSelectedId] = useState(null);
91
+ const [composeOpen, setComposeOpen] = useState(false);
92
+ const [liveEvent, setLiveEvent] = useState(null);
93
+ const [refreshToken, setRefreshToken] = useState(0);
94
+
95
+ const mountedRef = useRef(true);
96
+ const seqRef = useRef(0);
97
+
98
+ useEffect(
99
+ () => () => {
100
+ mountedRef.current = false;
101
+ },
102
+ []
103
+ );
104
+
105
+ const threadParams = useMemo(
106
+ () => ({ client_id: clientId ?? null }),
107
+ [clientId]
108
+ );
109
+
110
+ const { threads, loading, loaded, reload, applyThread, upsert, clearUnread } =
111
+ useSmsThreads({
112
+ endpoints,
113
+ params: threadParams,
114
+ perPage: PER_PAGE,
115
+ // No client, nothing to ask for — a card mounted before the record
116
+ // has loaded must not fetch every thread in the practice.
117
+ enabled: Boolean(clientId),
118
+ });
119
+
120
+ /* -------------------------------------------------------------- loading */
121
+
122
+ useEffect(() => {
123
+ if (linesProp) return undefined;
124
+
125
+ CustomFetch(
126
+ routes.lines,
127
+ 'GET',
128
+ null,
129
+ (result) => {
130
+ if (!mountedRef.current) return;
131
+
132
+ setOwnLines(Array.isArray(result?.data) ? result.data : []);
133
+ },
134
+ () => {
135
+ if (mountedRef.current) setOwnLines([]);
136
+ }
137
+ ).catch(() => {
138
+ if (mountedRef.current) setOwnLines([]);
139
+ });
140
+
141
+ return undefined;
142
+ }, [routes, linesProp]);
143
+
144
+ useEffect(() => {
145
+ if (statusProp) return undefined;
146
+
147
+ CustomFetch(
148
+ routes.status,
149
+ 'GET',
150
+ null,
151
+ (result) => {
152
+ if (mountedRef.current && result) setOwnStatus(result);
153
+ },
154
+ () => {}
155
+ ).catch(() => {});
156
+
157
+ return undefined;
158
+ }, [routes, statusProp]);
159
+
160
+ const lines = linesProp ?? ownLines ?? [];
161
+ const status = statusProp ?? ownStatus;
162
+
163
+ /* ----------------------------------------------------------------- live */
164
+
165
+ const belongsHere = useCallback(
166
+ (thread) => {
167
+ if (!thread) return false;
168
+
169
+ // Already on screen: an update to a conversation we are showing is
170
+ // ours whatever the payload says about its client, including the
171
+ // moment somebody unlinks it.
172
+ if (threads.some((one) => String(one.id) === String(thread.id))) return true;
173
+
174
+ return String(thread.client?.id ?? '') === String(clientId ?? '');
175
+ },
176
+ [threads, clientId]
177
+ );
178
+
179
+ const pushEvent = useCallback(
180
+ (event) => {
181
+ if (!mountedRef.current) return;
182
+
183
+ // The subscription is per line, so most of what arrives is another
184
+ // client's conversation. It still goes down to the panel, which
185
+ // matches on thread id — it simply never joins this rail.
186
+ if (event?.thread && belongsHere(event.thread)) upsert(event.thread);
187
+
188
+ seqRef.current += 1;
189
+ setLiveEvent({ seq: seqRef.current, payload: event });
190
+ },
191
+ [belongsHere, upsert]
192
+ );
193
+
194
+ useSmsLive({
195
+ echo,
196
+ lines,
197
+ channelFor,
198
+ endpoints,
199
+ onReceived: pushEvent,
200
+ onUpdated: pushEvent,
201
+ // Only ticks while there is no Echo: re-read the rail, and tell the
202
+ // conversation to re-read itself.
203
+ onPoll: useCallback(() => {
204
+ reload();
205
+ setRefreshToken((prev) => prev + 1);
206
+ }, [reload]),
207
+ });
208
+
209
+ /* ------------------------------------------------------------ selection */
210
+
211
+ const ordered = useMemo(() => orderThreadsByRecency(threads), [threads]);
212
+
213
+ // Open on something as soon as there is something to open on, and stay
214
+ // there: `pickInitialThread` prefers the id already selected, so a poll or
215
+ // an arriving message cannot move the reader to another conversation.
216
+ useEffect(() => {
217
+ const next = pickInitialThread(ordered, selectedId);
218
+ const nextId = next?.id ?? null;
219
+
220
+ if (String(nextId ?? '') !== String(selectedId ?? '')) setSelectedId(nextId);
221
+ }, [ordered, selectedId]);
222
+
223
+ const select = useCallback(
224
+ (thread) => {
225
+ const id = thread?.id ?? null;
226
+
227
+ setSelectedId(id);
228
+ clearUnread(id);
229
+ },
230
+ [clearUnread]
231
+ );
232
+
233
+ /* --------------------------------------------------------------- render */
234
+
235
+ const recipient = useMemo(
236
+ () => clientRecipient({ id: clientId, name: clientName, numbers }),
237
+ [clientId, clientName, numbers]
238
+ );
239
+
240
+ const lineLabel = useCallback(
241
+ (thread) => {
242
+ const line = lines.find((one) => String(one.id) === String(thread?.line_id));
243
+
244
+ return line?.label || lineNumberForDisplay(line) || 'Line';
245
+ },
246
+ [lines]
247
+ );
248
+
249
+ const unreadTotal = threads.reduce(
250
+ (total, thread) => total + (Number(thread.unread_count) || 0),
251
+ 0
252
+ );
253
+
254
+ const canCompose = recipient !== null && lines.length > 0;
255
+ const who = String(clientName || '').trim() || 'this client';
256
+
257
+ const inboxHref = selectedId
258
+ ? `${inboxUrl}${inboxUrl.includes('?') ? '&' : '?'}thread=${selectedId}`
259
+ : inboxUrl;
260
+
261
+ const openInbox = (event) => {
262
+ if (!navigate) return;
263
+
264
+ event.preventDefault();
265
+ navigate(inboxHref);
266
+ };
267
+
268
+ const composeButton = (
269
+ <button
270
+ type="button"
271
+ className={styles.primaryButton}
272
+ onClick={() => setComposeOpen(true)}
273
+ disabled={!canCompose}
274
+ title={
275
+ canCompose
276
+ ? undefined
277
+ : recipient === null
278
+ ? 'No mobile number on this client record'
279
+ : 'No messaging line is available to you'
280
+ }
281
+ >
282
+ <PenSquare size={14} strokeWidth={2} aria-hidden="true" />
283
+ New message
284
+ </button>
285
+ );
286
+
287
+ const empty = loaded && threads.length === 0;
288
+
289
+ return (
290
+ <section className={`${styles.clientCard} ${className}`.trim()}>
291
+ <header className={styles.clientCardHead}>
292
+ <div className={styles.clientCardHeading}>
293
+ <h2 className={styles.clientCardTitle}>
294
+ <MessageSquare size={17} strokeWidth={2.2} aria-hidden="true" />
295
+ {title}
296
+ </h2>
297
+ <p className={styles.clientCardMeta}>
298
+ {conversationCountLabel(threads.length)}
299
+ {unreadTotal > 0 ? ` · ${unreadTotal} unread` : ''}
300
+ </p>
301
+ </div>
302
+
303
+ <div className={styles.clientCardActions}>
304
+ <a
305
+ className={styles.ghostButton}
306
+ href={inboxHref}
307
+ onClick={openInbox}
308
+ >
309
+ <ExternalLink size={14} strokeWidth={2} aria-hidden="true" />
310
+ Open in Messages
311
+ </a>
312
+ {composeButton}
313
+ </div>
314
+ </header>
315
+
316
+ {empty ? (
317
+ <div className={`${styles.clientCardBody} ${styles.clientEmpty}`}>
318
+ <p>No text messages with {who} yet</p>
319
+ {composeButton}
320
+ </div>
321
+ ) : (
322
+ <div
323
+ className={`${styles.panes} ${styles.clientPanes}`}
324
+ style={{
325
+ height: typeof height === 'number' ? `${height}px` : height,
326
+ }}
327
+ >
328
+ <div className={styles.listPane} aria-label="Conversations">
329
+ <ul className={`${styles.threadList} ${styles.clientRail}`}>
330
+ {ordered.map((thread) => {
331
+ const unread = Number(thread.unread_count) || 0;
332
+ const active = String(thread.id) === String(selectedId);
333
+
334
+ return (
335
+ <li key={thread.id}>
336
+ <button
337
+ type="button"
338
+ className={[
339
+ styles.threadRow,
340
+ styles.clientRow,
341
+ active ? styles.threadRowActive : '',
342
+ unread > 0 ? styles.threadUnread : '',
343
+ ]
344
+ .filter(Boolean)
345
+ .join(' ')}
346
+ aria-current={active ? 'true' : undefined}
347
+ onClick={() => select(thread)}
348
+ >
349
+ <span
350
+ className={styles.threadAccent}
351
+ aria-hidden="true"
352
+ />
353
+
354
+ <span className={styles.threadBody}>
355
+ <span className={styles.threadTop}>
356
+ <span className={styles.threadName}>
357
+ {lineLabel(thread)}
358
+ </span>
359
+ <span className={styles.threadTime}>
360
+ {relativeTime(
361
+ thread.last_message?.at ??
362
+ thread.updated_at
363
+ )}
364
+ </span>
365
+ </span>
366
+
367
+ <span className={styles.clientRowNumber}>
368
+ {threadSubtitle(thread) ||
369
+ thread.display_number}
370
+ </span>
371
+
372
+ <span className={styles.threadPreview}>
373
+ <span>{previewOf(thread)}</span>
374
+ {unread > 0 && (
375
+ <span className={styles.threadCount}>
376
+ {unread}
377
+ </span>
378
+ )}
379
+ </span>
380
+ </span>
381
+ </button>
382
+ </li>
383
+ );
384
+ })}
385
+
386
+ {ordered.length === 0 && (
387
+ <li>
388
+ <div className={styles.tableEmpty}>
389
+ <p>{loading ? 'Loading…' : 'No conversations.'}</p>
390
+ </div>
391
+ </li>
392
+ )}
393
+ </ul>
394
+ </div>
395
+
396
+ <SmsThreadPanel
397
+ threadId={selectedId}
398
+ endpoints={endpoints}
399
+ // The card holds the subscription; the pane is fed from
400
+ // it. Two subscriptions to one channel is two websocket
401
+ // authorisations and two copies of every message.
402
+ subscribe={false}
403
+ liveEvent={liveEvent}
404
+ refreshToken={refreshToken}
405
+ compact
406
+ status={status}
407
+ templates={templatesProp}
408
+ lines={lines}
409
+ canManage={canManage}
410
+ clientUrl={clientUrl}
411
+ onThreadChange={applyThread}
412
+ onMessage={applyThread}
413
+ showTransportBanner={false}
414
+ />
415
+ </div>
416
+ )}
417
+
418
+ <SmsComposeModal
419
+ open={composeOpen}
420
+ onClose={() => setComposeOpen(false)}
421
+ endpoints={endpoints}
422
+ lines={lines}
423
+ status={status}
424
+ templates={templatesProp}
425
+ initialRecipient={recipient}
426
+ onCreated={(thread) => {
427
+ setComposeOpen(false);
428
+
429
+ if (!thread) return;
430
+
431
+ upsert(thread);
432
+ select(thread);
433
+ }}
434
+ />
435
+ </section>
436
+ );
437
+ };
438
+
439
+ export default SmsClientConversations;