@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
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.
|
|
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.
|
|
98
|
+
"version": "6.17.1",
|
|
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;
|