@visns-studio/visns-components 6.6.3 → 6.15.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 +1658 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +619 -158
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/AutoGrowCell.jsx +141 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/styles/global-datagrid.css +29 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- package/src/index.js +233 -1
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the messaging UI knows that does not need React.
|
|
3
|
+
*
|
|
4
|
+
* Number formatting, GSM-7 vs UCS-2 segment counting, relative times, day
|
|
5
|
+
* grouping and the small list merges the live updates run through. Kept in a
|
|
6
|
+
* plain `.js` module with no JSX and no DOM so `node --test` can import it
|
|
7
|
+
* directly — the same split the vault and the call queue already use.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/* ------------------------------------------------------------------ numbers */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Country codes worth recognising by sight.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately short. Australian numbers are formatted properly below; this
|
|
16
|
+
* list only exists so the handful of overseas numbers a Perth advice firm
|
|
17
|
+
* actually messages (New Zealand, the UK, Singapore, the US) get their code
|
|
18
|
+
* split off rather than swallowed into the first group of digits. Anything
|
|
19
|
+
* not listed falls back to a two-digit code, which is the commonest length.
|
|
20
|
+
*/
|
|
21
|
+
const COUNTRY_CODES = [
|
|
22
|
+
'1',
|
|
23
|
+
'7',
|
|
24
|
+
'20', '27', '30', '31', '32', '33', '34', '36', '39', '40', '41', '43',
|
|
25
|
+
'44', '45', '46', '47', '48', '49', '51', '52', '53', '54', '55', '56',
|
|
26
|
+
'57', '58', '60', '62', '63', '64', '65', '66', '81', '82', '84', '86',
|
|
27
|
+
'90', '91', '92', '93', '94', '95', '98',
|
|
28
|
+
'212', '213', '234', '351', '352', '353', '354', '358', '359', '370',
|
|
29
|
+
'380', '385', '386', '420', '421', '852', '853', '855', '856', '880',
|
|
30
|
+
'886', '960', '961', '962', '963', '964', '965', '966', '967', '968',
|
|
31
|
+
'971', '972', '973', '974', '975', '976', '977', '992', '993', '994',
|
|
32
|
+
'995', '998',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Strip the punctuation people type into a phone field. */
|
|
36
|
+
const compactNumber = (value) =>
|
|
37
|
+
String(value ?? '')
|
|
38
|
+
.trim()
|
|
39
|
+
.replace(/[\s()\-.‐-―]/g, '');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Does this read as a phone number at all?
|
|
43
|
+
*
|
|
44
|
+
* Used by the compose field to decide between "send to this number" and "keep
|
|
45
|
+
* searching for a client". Deliberately loose about punctuation and strict
|
|
46
|
+
* about content: a `+`, then digits, and nothing else.
|
|
47
|
+
*/
|
|
48
|
+
export const looksLikeNumber = (value) => {
|
|
49
|
+
const compact = compactNumber(value);
|
|
50
|
+
|
|
51
|
+
return /^\+?\d{6,17}$/.test(compact);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Group a run of digits from the left in threes, absorbing a short tail. */
|
|
55
|
+
const groupThrees = (digits) => {
|
|
56
|
+
const groups = [];
|
|
57
|
+
|
|
58
|
+
for (let index = 0; index < digits.length; index += 3) {
|
|
59
|
+
groups.push(digits.slice(index, index + 3));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A trailing group of one or two digits reads as a typo. Fold it back into
|
|
63
|
+
// its neighbour so the last block is four or five rather than "555 0 123".
|
|
64
|
+
if (groups.length > 1 && groups[groups.length - 1].length < 3) {
|
|
65
|
+
const tail = groups.pop();
|
|
66
|
+
|
|
67
|
+
groups[groups.length - 1] += tail;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return groups.join(' ');
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** `+14155550123` -> `+1 415 555 0123`. */
|
|
74
|
+
const formatInternational = (compact) => {
|
|
75
|
+
const digits = compact.replace(/^\+/, '');
|
|
76
|
+
// Longest match wins, so `353` is not read as `35`.
|
|
77
|
+
const code =
|
|
78
|
+
[3, 2, 1]
|
|
79
|
+
.map((length) => digits.slice(0, length))
|
|
80
|
+
.find((candidate) => COUNTRY_CODES.includes(candidate)) ??
|
|
81
|
+
digits.slice(0, 2);
|
|
82
|
+
|
|
83
|
+
return `+${code} ${groupThrees(digits.slice(code.length))}`.trim();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The Australian national significant number, formatted the local way. */
|
|
87
|
+
const formatAustralian = (national) => {
|
|
88
|
+
// Mobile: 04XX XXX XXX.
|
|
89
|
+
if (/^4\d{8}$/.test(national)) {
|
|
90
|
+
return `0${national.slice(0, 3)} ${national.slice(3, 6)} ${national.slice(6)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Landline: (0X) XXXX XXXX.
|
|
94
|
+
if (/^[2378]\d{8}$/.test(national)) {
|
|
95
|
+
return `(0${national[0]}) ${national.slice(1, 5)} ${national.slice(5)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 1300 / 1800 / 1900: XXXX XXX XXX.
|
|
99
|
+
if (/^1(300|800|900)\d{6}$/.test(national)) {
|
|
100
|
+
return `${national.slice(0, 4)} ${national.slice(4, 7)} ${national.slice(7)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 13 XX XX.
|
|
104
|
+
if (/^13\d{4}$/.test(national)) {
|
|
105
|
+
return `13 ${national.slice(2, 4)} ${national.slice(4)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A number as a person in Perth would write it.
|
|
113
|
+
*
|
|
114
|
+
* The backend stores and sends E.164 (`+61412345678`) because that is what
|
|
115
|
+
* Zoom wants; nobody reads it that way. Australian numbers come back in the
|
|
116
|
+
* local form, everything else keeps its `+` and country code. Anything that
|
|
117
|
+
* is not a number at all — a name, an empty cell, a half-typed field — comes
|
|
118
|
+
* back untouched rather than mangled, because this also formats whatever the
|
|
119
|
+
* user is typing.
|
|
120
|
+
*/
|
|
121
|
+
export const normaliseNumberForDisplay = (value) => {
|
|
122
|
+
const raw = String(value ?? '').trim();
|
|
123
|
+
|
|
124
|
+
if (raw === '') return '';
|
|
125
|
+
|
|
126
|
+
const compact = compactNumber(raw);
|
|
127
|
+
|
|
128
|
+
if (!/^\+?\d{4,}$/.test(compact)) return raw;
|
|
129
|
+
|
|
130
|
+
let national = null;
|
|
131
|
+
|
|
132
|
+
if (compact.startsWith('+61')) national = compact.slice(3);
|
|
133
|
+
else if (compact.startsWith('0061')) national = compact.slice(4);
|
|
134
|
+
else if (compact.startsWith('+')) return formatInternational(compact);
|
|
135
|
+
else if (compact.startsWith('0')) national = compact.slice(1);
|
|
136
|
+
else national = compact;
|
|
137
|
+
|
|
138
|
+
return formatAustralian(national) ?? raw;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The other direction: a local number as E.164, for sending.
|
|
143
|
+
*
|
|
144
|
+
* The backend does its own parsing — it has to, it is the one talking to Zoom
|
|
145
|
+
* — so this is only ever a hint the compose box shows next to what was typed.
|
|
146
|
+
* Returns null when it cannot tell.
|
|
147
|
+
*/
|
|
148
|
+
export const toE164 = (value, countryCode = '61') => {
|
|
149
|
+
const compact = compactNumber(value);
|
|
150
|
+
|
|
151
|
+
if (compact === '') return null;
|
|
152
|
+
if (/^\+\d{7,17}$/.test(compact)) return compact;
|
|
153
|
+
if (/^00\d{7,17}$/.test(compact)) return `+${compact.slice(2)}`;
|
|
154
|
+
if (/^0\d{8,9}$/.test(compact)) return `+${countryCode}${compact.slice(1)}`;
|
|
155
|
+
if (/^\d{9}$/.test(compact)) return `+${countryCode}${compact}`;
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/* ----------------------------------------------------------------- segments */
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The GSM 03.38 basic alphabet — one septet each.
|
|
164
|
+
*
|
|
165
|
+
* Written out rather than expressed as ranges because it is not one: it is a
|
|
166
|
+
* 1980s telco table with Greek capitals and a section symbol in the middle of
|
|
167
|
+
* it, and every character missing from here is what pushes a message from 160
|
|
168
|
+
* characters to 70.
|
|
169
|
+
*/
|
|
170
|
+
const GSM_BASIC =
|
|
171
|
+
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?' +
|
|
172
|
+
'¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
|
|
173
|
+
|
|
174
|
+
/** Characters that exist in GSM-7, but cost two septets to send. */
|
|
175
|
+
const GSM_EXTENDED = '\f^{}\\[~]|€';
|
|
176
|
+
|
|
177
|
+
const GSM_BASIC_SET = new Set(GSM_BASIC.split(''));
|
|
178
|
+
const GSM_EXTENDED_SET = new Set(GSM_EXTENDED.split(''));
|
|
179
|
+
|
|
180
|
+
export const SMS_LIMITS = {
|
|
181
|
+
'GSM-7': { single: 160, concatenated: 153 },
|
|
182
|
+
'UCS-2': { single: 70, concatenated: 67 },
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** The most segments the composer will send in one go. */
|
|
186
|
+
export const MAX_SEGMENTS = 6;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* How many SMS this body will actually cost, and why.
|
|
190
|
+
*
|
|
191
|
+
* The rule that surprises people is the second one: a single curly quote, an
|
|
192
|
+
* em dash or an emoji anywhere in the body drops the whole message to UCS-2
|
|
193
|
+
* and the limit falls from 160 characters to 70. The composer shows the
|
|
194
|
+
* encoding for exactly that reason — "70" with no explanation looks like a
|
|
195
|
+
* bug.
|
|
196
|
+
*
|
|
197
|
+
* Counted in UTF-16 code units, which is what the encoding actually charges
|
|
198
|
+
* for: an emoji outside the BMP is a surrogate pair and costs two.
|
|
199
|
+
*/
|
|
200
|
+
export const segmentCount = (body) => {
|
|
201
|
+
const text = String(body ?? '');
|
|
202
|
+
|
|
203
|
+
let septets = 0;
|
|
204
|
+
let unicode = false;
|
|
205
|
+
|
|
206
|
+
for (const character of text) {
|
|
207
|
+
if (GSM_BASIC_SET.has(character)) {
|
|
208
|
+
septets += 1;
|
|
209
|
+
} else if (GSM_EXTENDED_SET.has(character)) {
|
|
210
|
+
// An escape byte, then the character itself.
|
|
211
|
+
septets += 2;
|
|
212
|
+
} else {
|
|
213
|
+
unicode = true;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const encoding = unicode ? 'UCS-2' : 'GSM-7';
|
|
219
|
+
const limits = SMS_LIMITS[encoding];
|
|
220
|
+
const characters = unicode ? text.length : septets;
|
|
221
|
+
|
|
222
|
+
if (characters === 0) {
|
|
223
|
+
return {
|
|
224
|
+
encoding,
|
|
225
|
+
characters: 0,
|
|
226
|
+
segments: 0,
|
|
227
|
+
perSegment: limits.single,
|
|
228
|
+
remaining: limits.single,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const segments =
|
|
233
|
+
characters <= limits.single
|
|
234
|
+
? 1
|
|
235
|
+
: Math.ceil(characters / limits.concatenated);
|
|
236
|
+
const perSegment = segments === 1 ? limits.single : limits.concatenated;
|
|
237
|
+
const capacity = segments * perSegment;
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
encoding,
|
|
241
|
+
characters,
|
|
242
|
+
segments,
|
|
243
|
+
perSegment,
|
|
244
|
+
remaining: capacity - characters,
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/* -------------------------------------------------------------------- time */
|
|
249
|
+
|
|
250
|
+
const MINUTE = 60_000;
|
|
251
|
+
const HOUR = 60 * MINUTE;
|
|
252
|
+
const DAY = 24 * HOUR;
|
|
253
|
+
|
|
254
|
+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
255
|
+
const MONTHS = [
|
|
256
|
+
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
257
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
/** Midnight local time, as a number, for the day a date falls in. */
|
|
261
|
+
const startOfDay = (date) =>
|
|
262
|
+
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
263
|
+
|
|
264
|
+
export const parseDate = (value) => {
|
|
265
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
266
|
+
if (value === null || value === undefined || value === '') return null;
|
|
267
|
+
|
|
268
|
+
// MySQL hands back `2026-08-21 07:45:47`, which Safari refuses to parse.
|
|
269
|
+
const text =
|
|
270
|
+
typeof value === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:/.test(value)
|
|
271
|
+
? value.replace(' ', 'T')
|
|
272
|
+
: value;
|
|
273
|
+
const date = new Date(text);
|
|
274
|
+
|
|
275
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The short time a message list shows: `Now`, `4m`, `3h`, `Yesterday`, `Tue`,
|
|
280
|
+
* `12 Aug`, `12 Aug 24`.
|
|
281
|
+
*
|
|
282
|
+
* `now` is injectable so the tests are not a function of when they run.
|
|
283
|
+
*/
|
|
284
|
+
export const relativeTime = (value, now = Date.now()) => {
|
|
285
|
+
const date = parseDate(value);
|
|
286
|
+
|
|
287
|
+
if (!date) return '';
|
|
288
|
+
|
|
289
|
+
const stamp = date.getTime();
|
|
290
|
+
const elapsed = now - stamp;
|
|
291
|
+
|
|
292
|
+
if (elapsed < 0) return 'Now';
|
|
293
|
+
if (elapsed < MINUTE) return 'Now';
|
|
294
|
+
if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m`;
|
|
295
|
+
if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h`;
|
|
296
|
+
|
|
297
|
+
const today = startOfDay(new Date(now));
|
|
298
|
+
const days = Math.round((today - startOfDay(date)) / DAY);
|
|
299
|
+
|
|
300
|
+
if (days === 1) return 'Yesterday';
|
|
301
|
+
if (days < 7) return WEEKDAYS[date.getDay()];
|
|
302
|
+
|
|
303
|
+
const sameYear = date.getFullYear() === new Date(now).getFullYear();
|
|
304
|
+
const stem = `${date.getDate()} ${MONTHS[date.getMonth()]}`;
|
|
305
|
+
|
|
306
|
+
return sameYear ? stem : `${stem} ${String(date.getFullYear()).slice(-2)}`;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** `2:07 pm`. Lower case, because a bubble is not a spreadsheet. */
|
|
310
|
+
export const clockTime = (value) => {
|
|
311
|
+
const date = parseDate(value);
|
|
312
|
+
|
|
313
|
+
if (!date) return '';
|
|
314
|
+
|
|
315
|
+
const hours = date.getHours();
|
|
316
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
317
|
+
const suffix = hours < 12 ? 'am' : 'pm';
|
|
318
|
+
const twelve = hours % 12 === 0 ? 12 : hours % 12;
|
|
319
|
+
|
|
320
|
+
return `${twelve}:${minutes} ${suffix}`;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** The heading over a day's worth of messages. */
|
|
324
|
+
export const dayLabel = (value, now = Date.now()) => {
|
|
325
|
+
const date = parseDate(value);
|
|
326
|
+
|
|
327
|
+
if (!date) return '';
|
|
328
|
+
|
|
329
|
+
const today = startOfDay(new Date(now));
|
|
330
|
+
const days = Math.round((today - startOfDay(date)) / DAY);
|
|
331
|
+
|
|
332
|
+
if (days === 0) return 'Today';
|
|
333
|
+
if (days === 1) return 'Yesterday';
|
|
334
|
+
|
|
335
|
+
const stem = `${WEEKDAYS[date.getDay()]} ${date.getDate()} ${MONTHS[date.getMonth()]}`;
|
|
336
|
+
|
|
337
|
+
return date.getFullYear() === new Date(now).getFullYear()
|
|
338
|
+
? stem
|
|
339
|
+
: `${stem} ${date.getFullYear()}`;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/* ------------------------------------------------------------------ people */
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Titles that are not part of anybody's name.
|
|
346
|
+
*
|
|
347
|
+
* The CRM stores clients as "Perera, Darshini (Mrs)", so the bracketed title
|
|
348
|
+
* is the LAST word of the string — which is exactly where a naive
|
|
349
|
+
* first-word-plus-last-word rule looks for the surname, and why the avatars
|
|
350
|
+
* used to read "P(".
|
|
351
|
+
*/
|
|
352
|
+
const HONORIFICS = new Set([
|
|
353
|
+
'mr', 'mrs', 'ms', 'miss', 'mx', 'dr', 'prof', 'professor',
|
|
354
|
+
'rev', 'reverend', 'sir', 'dame', 'madam', 'lady', 'lord',
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
/** Drop anything in brackets, closed or not: "(Mrs)", "[deceased]", "(Mrs". */
|
|
358
|
+
const stripBrackets = (value) => value.replace(/[([{][^)\]}]*([)\]}]|$)/g, ' ');
|
|
359
|
+
|
|
360
|
+
/** The name words, with titles and punctuation-only fragments removed. */
|
|
361
|
+
const nameWords = (value) =>
|
|
362
|
+
String(value)
|
|
363
|
+
.split(/[\s.]+/)
|
|
364
|
+
.map((word) => word.trim())
|
|
365
|
+
.filter((word) => word !== '')
|
|
366
|
+
.filter((word) => !HONORIFICS.has(word.replace(/[^a-z]/gi, '').toLowerCase()));
|
|
367
|
+
|
|
368
|
+
/** The first letter or digit in a word — "d'Angelo" must not initial as an apostrophe. */
|
|
369
|
+
const firstLetter = (word) => {
|
|
370
|
+
const match = String(word || '').match(/[\p{L}\p{N}]/u);
|
|
371
|
+
|
|
372
|
+
return match ? match[0] : '';
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* At most two letters for an avatar.
|
|
377
|
+
*
|
|
378
|
+
* The CRM writes client names surname-first with a title on the end —
|
|
379
|
+
* "Perera, Darshini (Mrs)" — so three passes are needed before the initials
|
|
380
|
+
* mean anything: drop the brackets, drop the title, and read a comma as
|
|
381
|
+
* "Surname, First" so the initials come out in the order a person says them
|
|
382
|
+
* ("DP", not "PD").
|
|
383
|
+
*
|
|
384
|
+
* A number gets `#` rather than two of its digits: a circle reading "78" looks
|
|
385
|
+
* like data, and the only honest thing to say about an unsaved number is that
|
|
386
|
+
* nobody has named it yet. Same for nothing at all.
|
|
387
|
+
*/
|
|
388
|
+
export const initialsFor = (value) => {
|
|
389
|
+
const raw = String(value ?? '').trim();
|
|
390
|
+
|
|
391
|
+
if (raw === '' || looksLikeNumber(raw)) return '#';
|
|
392
|
+
|
|
393
|
+
const cleaned = stripBrackets(raw);
|
|
394
|
+
|
|
395
|
+
// "Surname, First Middle" — the CRM's own format.
|
|
396
|
+
if (cleaned.includes(',')) {
|
|
397
|
+
const [surname, ...rest] = cleaned.split(',');
|
|
398
|
+
const surnameWords = nameWords(surname);
|
|
399
|
+
const givenWords = nameWords(rest.join(' '));
|
|
400
|
+
const given = firstLetter(givenWords[0]);
|
|
401
|
+
const family = firstLetter(surnameWords[0]);
|
|
402
|
+
|
|
403
|
+
if (given && family) return `${given}${family}`.toUpperCase();
|
|
404
|
+
// Only one half survived the cleaning — fall through to the plain rule
|
|
405
|
+
// rather than returning half an avatar.
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const words = nameWords(cleaned);
|
|
409
|
+
|
|
410
|
+
if (words.length === 0) return '#';
|
|
411
|
+
|
|
412
|
+
if (words.length === 1) {
|
|
413
|
+
const single = words[0].replace(/[^\p{L}\p{N}]/gu, '').slice(0, 2);
|
|
414
|
+
|
|
415
|
+
return single === '' ? '#' : single.toUpperCase();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const first = firstLetter(words[0]);
|
|
419
|
+
const last = firstLetter(words[words.length - 1]);
|
|
420
|
+
const pair = `${first}${last}`;
|
|
421
|
+
|
|
422
|
+
return pair === '' ? '#' : pair.slice(0, 2).toUpperCase();
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A CRM client name split into the two halves a template needs.
|
|
427
|
+
*
|
|
428
|
+
* The CRM writes clients surname-first with a title on the end — "Second, Test
|
|
429
|
+
* (Mr)" — so "Hi {first_name}" cannot be answered by taking the first word.
|
|
430
|
+
* Three passes, the same three `initialsFor` makes: drop the brackets, drop
|
|
431
|
+
* the honorific, and read a comma as "Surname, First".
|
|
432
|
+
*
|
|
433
|
+
* A number is not a name, and neither half is guessed from one.
|
|
434
|
+
*/
|
|
435
|
+
export const splitPersonName = (value) => {
|
|
436
|
+
const raw = String(value ?? '').trim();
|
|
437
|
+
const nothing = { first: null, last: null };
|
|
438
|
+
|
|
439
|
+
if (raw === '' || looksLikeNumber(raw)) return nothing;
|
|
440
|
+
|
|
441
|
+
const cleaned = stripBrackets(raw).replace(/\s+/g, ' ').trim();
|
|
442
|
+
|
|
443
|
+
if (cleaned.includes(',')) {
|
|
444
|
+
const [surname, ...rest] = cleaned.split(',');
|
|
445
|
+
const given = nameWords(rest.join(' '));
|
|
446
|
+
const family = nameWords(surname);
|
|
447
|
+
|
|
448
|
+
if (given.length > 0 || family.length > 0) {
|
|
449
|
+
return { first: given[0] ?? null, last: family[0] ?? null };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return nothing;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const words = nameWords(cleaned);
|
|
456
|
+
|
|
457
|
+
if (words.length === 0) return nothing;
|
|
458
|
+
if (words.length === 1) return { first: words[0], last: null };
|
|
459
|
+
|
|
460
|
+
return { first: words[0], last: words[words.length - 1] };
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
/* --------------------------------------------------------------- templates */
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A placeholder in a template body.
|
|
467
|
+
*
|
|
468
|
+
* `{first_name}`, and the three things people type instead of it: `{{...}}`,
|
|
469
|
+
* `{ ... }` and any mixture of case. Deliberately narrow about what a token
|
|
470
|
+
* may contain (a word) so a body that happens to contain a brace — a dollar
|
|
471
|
+
* figure in curly quotes, a stray `{` — is not eaten.
|
|
472
|
+
*/
|
|
473
|
+
const PLACEHOLDER = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}|\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g;
|
|
474
|
+
|
|
475
|
+
/** `Mon 24 Aug` — the way a reminder text says a date in Perth. */
|
|
476
|
+
const templateDate = (value) => {
|
|
477
|
+
const date = parseDate(value);
|
|
478
|
+
|
|
479
|
+
if (!date) return null;
|
|
480
|
+
|
|
481
|
+
return `${WEEKDAYS[date.getDay()]} ${date.getDate()} ${MONTHS[date.getMonth()]}`;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const trimOrNull = (value) => {
|
|
485
|
+
const text = String(value ?? '').trim();
|
|
486
|
+
|
|
487
|
+
return text === '' ? null : text;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* What each token is worth for one conversation.
|
|
492
|
+
*
|
|
493
|
+
* The fallbacks are the point. A thread carries whatever the CRM could tell it
|
|
494
|
+
* — sometimes a first name, sometimes only "Second, Test (Mr)", sometimes only
|
|
495
|
+
* a contact label somebody typed — and a template has to do the best it can
|
|
496
|
+
* with each. Anything still unknown comes back null, which leaves the token
|
|
497
|
+
* standing in the body.
|
|
498
|
+
*/
|
|
499
|
+
const templateValues = (context = {}) => {
|
|
500
|
+
const client = context.client || {};
|
|
501
|
+
const contactName = context.contact_name ?? context.contactName ?? null;
|
|
502
|
+
|
|
503
|
+
const fromClient = splitPersonName(client.name);
|
|
504
|
+
const fromContact = splitPersonName(contactName);
|
|
505
|
+
|
|
506
|
+
const first =
|
|
507
|
+
trimOrNull(client.first_name) ?? fromContact.first ?? fromClient.first;
|
|
508
|
+
// The other way round for the surname: the CRM's own "Surname, First" is a
|
|
509
|
+
// better source for it than a label someone typed on a phone number.
|
|
510
|
+
const last =
|
|
511
|
+
trimOrNull(client.last_name) ?? fromClient.last ?? fromContact.last;
|
|
512
|
+
|
|
513
|
+
// Never the raw stored form: "Hi Second, Test (Mr)" is not a text message.
|
|
514
|
+
const spoken = [first, last].filter(Boolean).join(' ');
|
|
515
|
+
const name =
|
|
516
|
+
trimOrNull(spoken) ??
|
|
517
|
+
trimOrNull(stripBrackets(String(client.name ?? ''))) ??
|
|
518
|
+
trimOrNull(stripBrackets(String(contactName ?? '')));
|
|
519
|
+
|
|
520
|
+
const eventDate = client.next_event?.date ?? null;
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
first_name: first,
|
|
524
|
+
last_name: last,
|
|
525
|
+
name,
|
|
526
|
+
full_name: name,
|
|
527
|
+
number: trimOrNull(normaliseNumberForDisplay(context.number)),
|
|
528
|
+
line: trimOrNull(context.line),
|
|
529
|
+
date: templateDate(eventDate),
|
|
530
|
+
time: trimOrNull(clockTime(eventDate)),
|
|
531
|
+
};
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* A template body with its placeholders filled in from the conversation.
|
|
536
|
+
*
|
|
537
|
+
* The rule that matters is what happens when a token cannot be filled: it is
|
|
538
|
+
* left exactly as written. A client with no meeting booked yields "your review
|
|
539
|
+
* on {date}", which is visibly a blank for the sender to type over — where
|
|
540
|
+
* dropping it would yield "your review on " and get sent that way. An unknown
|
|
541
|
+
* token is left standing for the same reason.
|
|
542
|
+
*/
|
|
543
|
+
export const fillTemplate = (body, context = {}) => {
|
|
544
|
+
const text = String(body ?? '');
|
|
545
|
+
|
|
546
|
+
if (text === '') return '';
|
|
547
|
+
|
|
548
|
+
const values = templateValues(context);
|
|
549
|
+
|
|
550
|
+
return text.replace(PLACEHOLDER, (match, doubled, single) => {
|
|
551
|
+
const key = String(doubled ?? single ?? '').toLowerCase();
|
|
552
|
+
const value = values[key];
|
|
553
|
+
|
|
554
|
+
return value === undefined || value === null || value === ''
|
|
555
|
+
? match
|
|
556
|
+
: value;
|
|
557
|
+
});
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Where the next unfilled placeholder sits, so the composer can select it
|
|
562
|
+
* rather than merely park the caret at the end.
|
|
563
|
+
*
|
|
564
|
+
* Selecting it is what turns "the template went in" into "type here": the
|
|
565
|
+
* sender's next keystroke replaces the token instead of landing beside it.
|
|
566
|
+
*/
|
|
567
|
+
export const nextPlaceholder = (text, from = 0) => {
|
|
568
|
+
const body = String(text ?? '');
|
|
569
|
+
const search = new RegExp(PLACEHOLDER.source, 'g');
|
|
570
|
+
|
|
571
|
+
search.lastIndex = Math.max(0, Number(from) || 0);
|
|
572
|
+
|
|
573
|
+
const match = search.exec(body);
|
|
574
|
+
|
|
575
|
+
return match === null
|
|
576
|
+
? null
|
|
577
|
+
: { start: match.index, end: match.index + match[0].length };
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
/** What a thread is called in a list: the client, the contact, the number. */
|
|
581
|
+
export const threadDisplayName = (thread) => {
|
|
582
|
+
if (!thread) return '';
|
|
583
|
+
|
|
584
|
+
return (
|
|
585
|
+
thread.client?.name ||
|
|
586
|
+
thread.contact_name ||
|
|
587
|
+
thread.display_number ||
|
|
588
|
+
normaliseNumberForDisplay(thread.external_number) ||
|
|
589
|
+
'Unknown number'
|
|
590
|
+
);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/** The number under the name, only when it is not already the name. */
|
|
594
|
+
export const threadSubtitle = (thread) => {
|
|
595
|
+
if (!thread) return '';
|
|
596
|
+
|
|
597
|
+
const number =
|
|
598
|
+
thread.display_number ||
|
|
599
|
+
normaliseNumberForDisplay(thread.external_number) ||
|
|
600
|
+
'';
|
|
601
|
+
|
|
602
|
+
return threadDisplayName(thread) === number ? '' : number;
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/* --------------------------------------------------------------- messages */
|
|
606
|
+
|
|
607
|
+
/** The moment a message happened, whichever of its stamps is set. */
|
|
608
|
+
export const messageTimestamp = (message) =>
|
|
609
|
+
message?.created_at ??
|
|
610
|
+
message?.received_at ??
|
|
611
|
+
message?.sent_at ??
|
|
612
|
+
message?.delivered_at ??
|
|
613
|
+
null;
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* What the status line under an outgoing bubble says.
|
|
617
|
+
*
|
|
618
|
+
* `not_connected` is a first-class outcome rather than an error: Zoom is not
|
|
619
|
+
* wired up yet, the message is genuinely saved and genuinely queued, and
|
|
620
|
+
* calling that "Failed" would send someone chasing a bug that does not exist.
|
|
621
|
+
*/
|
|
622
|
+
export const STATUS_LABELS = {
|
|
623
|
+
queued: 'Sending…',
|
|
624
|
+
sent: 'Sent',
|
|
625
|
+
delivered: 'Delivered',
|
|
626
|
+
failed: 'Failed',
|
|
627
|
+
received: 'Received',
|
|
628
|
+
not_connected: 'Held — not connected',
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
export const statusLabel = (message) => {
|
|
632
|
+
if (!message) return '';
|
|
633
|
+
|
|
634
|
+
const label = STATUS_LABELS[message.status];
|
|
635
|
+
|
|
636
|
+
if (!label) return message.status ? String(message.status) : '';
|
|
637
|
+
if (message.status === 'failed' && message.error) {
|
|
638
|
+
return `Failed — ${message.error}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return label;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Messages split into days, oldest first, each day labelled.
|
|
646
|
+
*
|
|
647
|
+
* The timeline renders one heading per day and never re-sorts inside a day:
|
|
648
|
+
* the server sends oldest→newest and an optimistic bubble is appended at the
|
|
649
|
+
* end, so the order on screen is the order it arrived in.
|
|
650
|
+
*/
|
|
651
|
+
export const groupMessagesByDay = (messages, now = Date.now()) => {
|
|
652
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
653
|
+
const groups = [];
|
|
654
|
+
let current = null;
|
|
655
|
+
|
|
656
|
+
list.forEach((message) => {
|
|
657
|
+
const date = parseDate(messageTimestamp(message));
|
|
658
|
+
// A message with no usable stamp — an optimistic one mid-flight, say —
|
|
659
|
+
// belongs with whatever is on screen rather than in a day of its own.
|
|
660
|
+
const key = date
|
|
661
|
+
? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
|
|
662
|
+
date.getDate()
|
|
663
|
+
).padStart(2, '0')}`
|
|
664
|
+
: current?.key ?? 'unknown';
|
|
665
|
+
|
|
666
|
+
if (!current || current.key !== key) {
|
|
667
|
+
current = {
|
|
668
|
+
key,
|
|
669
|
+
label: date ? dayLabel(date, now) : '',
|
|
670
|
+
messages: [],
|
|
671
|
+
};
|
|
672
|
+
groups.push(current);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
current.messages.push(message);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
return groups;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/* ------------------------------------------------------- list bookkeeping */
|
|
682
|
+
|
|
683
|
+
const sameId = (a, b) => String(a) === String(b);
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Put a thread at the top of the list, replacing any older copy of it.
|
|
687
|
+
*
|
|
688
|
+
* What a `.sms.received` event does to the inbox: the conversation that just
|
|
689
|
+
* moved goes to the top, and it appears there exactly once whether or not it
|
|
690
|
+
* was already on screen.
|
|
691
|
+
*/
|
|
692
|
+
export const upsertThread = (threads, thread) => {
|
|
693
|
+
if (!thread || thread.id === undefined) return Array.isArray(threads) ? threads : [];
|
|
694
|
+
|
|
695
|
+
const list = Array.isArray(threads) ? threads : [];
|
|
696
|
+
const rest = list.filter((one) => !sameId(one.id, thread.id));
|
|
697
|
+
const previous = list.find((one) => sameId(one.id, thread.id));
|
|
698
|
+
|
|
699
|
+
return [previous ? { ...previous, ...thread } : thread, ...rest];
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Add a message to a timeline, or reconcile the optimistic one it replaces.
|
|
704
|
+
*
|
|
705
|
+
* Two things arrive out of order here: the 201 response to a send, and the
|
|
706
|
+
* `.sms.updated` broadcast for the same message. Matching on `id` first and
|
|
707
|
+
* then on the `client_id` an optimistic bubble carries means neither one can
|
|
708
|
+
* produce a duplicate, whichever lands first.
|
|
709
|
+
*/
|
|
710
|
+
export const upsertMessage = (messages, message) => {
|
|
711
|
+
if (!message) return Array.isArray(messages) ? messages : [];
|
|
712
|
+
|
|
713
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
714
|
+
const index = list.findIndex(
|
|
715
|
+
(one) =>
|
|
716
|
+
(message.id !== undefined && sameId(one.id, message.id)) ||
|
|
717
|
+
(message.client_id !== undefined &&
|
|
718
|
+
one.client_id !== undefined &&
|
|
719
|
+
sameId(one.client_id, message.client_id))
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
if (index === -1) return [...list, message];
|
|
723
|
+
|
|
724
|
+
const next = list.slice();
|
|
725
|
+
|
|
726
|
+
next[index] = { ...list[index], ...message };
|
|
727
|
+
|
|
728
|
+
return next;
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
/** Prepend a page of older messages, dropping any this timeline already has. */
|
|
732
|
+
export const prependMessages = (messages, older) => {
|
|
733
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
734
|
+
const page = Array.isArray(older) ? older : [];
|
|
735
|
+
const known = new Set(list.map((one) => String(one.id)));
|
|
736
|
+
|
|
737
|
+
return [...page.filter((one) => !known.has(String(one.id))), ...list];
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
/** Total unread across a `{id: n}` map, ignoring anything unparseable. */
|
|
741
|
+
export const sumUnread = (byId) =>
|
|
742
|
+
Object.values(byId || {}).reduce((total, value) => {
|
|
743
|
+
const count = Number(value);
|
|
744
|
+
|
|
745
|
+
return total + (Number.isFinite(count) ? count : 0);
|
|
746
|
+
}, 0);
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* The `?thread=` a deep link carries, if any.
|
|
750
|
+
*
|
|
751
|
+
* Out here rather than in the page because it is the contract between a link
|
|
752
|
+
* anyone can paste and the conversation that opens — the popover builds these
|
|
753
|
+
* URLs, the inbox reads them, and both have to agree.
|
|
754
|
+
*/
|
|
755
|
+
export const threadIdFromSearch = (search) => {
|
|
756
|
+
const raw = String(search ?? '');
|
|
757
|
+
|
|
758
|
+
if (raw === '') return null;
|
|
759
|
+
|
|
760
|
+
const query = raw.startsWith('?') ? raw.slice(1) : raw;
|
|
761
|
+
const match = query.match(/(?:^|&)thread=([^&#]*)/);
|
|
762
|
+
|
|
763
|
+
if (!match) return null;
|
|
764
|
+
|
|
765
|
+
const value = decodeURIComponent(match[1]);
|
|
766
|
+
|
|
767
|
+
return value === '' ? null : value;
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
/** `99+` past the point where the exact number stops mattering. */
|
|
771
|
+
export const badgeCount = (count, ceiling = 99) => {
|
|
772
|
+
const value = Number(count) || 0;
|
|
773
|
+
|
|
774
|
+
if (value <= 0) return '';
|
|
775
|
+
|
|
776
|
+
return value > ceiling ? `${ceiling}+` : String(value);
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/* ------------------------------------------------------------- transports */
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* What the status endpoint means, in words a user can act on.
|
|
783
|
+
*
|
|
784
|
+
* Three states, and only one of them is "everything works". The other two are
|
|
785
|
+
* the ones this UI was built around: Zoom is not connected yet, and the dev
|
|
786
|
+
* transport that stands in for it while that is true.
|
|
787
|
+
*/
|
|
788
|
+
export const describeTransport = (status) => {
|
|
789
|
+
const transport = status?.transport ?? null;
|
|
790
|
+
const connected = Boolean(status?.connected);
|
|
791
|
+
|
|
792
|
+
if (transport === 'zoom' && connected) {
|
|
793
|
+
return {
|
|
794
|
+
tone: 'ok',
|
|
795
|
+
short: 'Connected',
|
|
796
|
+
title: 'Connected to Zoom Phone',
|
|
797
|
+
note: 'Messages send and receive normally.',
|
|
798
|
+
detail: 'Messages send and receive normally.',
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (transport === 'zoom') {
|
|
803
|
+
return {
|
|
804
|
+
tone: 'bad',
|
|
805
|
+
short: 'Zoom unavailable',
|
|
806
|
+
title: 'Zoom Phone is not answering',
|
|
807
|
+
note: 'Messages are held until it comes back.',
|
|
808
|
+
detail:
|
|
809
|
+
'Messages are being held and will send once the connection is back.',
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (transport === 'log') {
|
|
814
|
+
return {
|
|
815
|
+
tone: 'warn',
|
|
816
|
+
short: 'Dev transport',
|
|
817
|
+
title: 'Dev transport — replies are simulated',
|
|
818
|
+
note: 'Nothing leaves the building.',
|
|
819
|
+
detail:
|
|
820
|
+
'Nothing leaves the building: messages are written to the log instead of being sent.',
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
tone: 'warn',
|
|
826
|
+
short: 'Not connected',
|
|
827
|
+
title: 'Not connected to Zoom yet — messages you send are held',
|
|
828
|
+
note: 'They send once Zoom Phone is connected.',
|
|
829
|
+
detail:
|
|
830
|
+
'You can write and queue messages now. They will be delivered once Zoom Phone is connected.',
|
|
831
|
+
};
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* What the composer's placeholder says, which is not always the same thing.
|
|
836
|
+
*
|
|
837
|
+
* It followed `connected` alone, so on the dev transport it promised a message
|
|
838
|
+
* would be "held until Zoom is connected" — which is not what happens: the dev
|
|
839
|
+
* transport accepts the message and answers it. Three transports, three
|
|
840
|
+
* sentences.
|
|
841
|
+
*/
|
|
842
|
+
export const composerPlaceholder = (status) => {
|
|
843
|
+
const transport = status?.transport ?? null;
|
|
844
|
+
|
|
845
|
+
if (transport === 'log') {
|
|
846
|
+
return 'Write a message… (dev transport: a simulated reply comes back)';
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (transport === 'zoom' && status?.connected) return 'Write a message…';
|
|
850
|
+
|
|
851
|
+
return 'Write a message — it will be held until Zoom is connected';
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* The counter under the composer: `0 / 160 · 1 SMS · GSM-7`.
|
|
856
|
+
*
|
|
857
|
+
* A bare "70" looks like a bug, so the denominator moves with the encoding and
|
|
858
|
+
* the encoding is named. The segment count floors at one — a message about to
|
|
859
|
+
* be typed still costs one SMS, and "0 SMS" invites the question.
|
|
860
|
+
*/
|
|
861
|
+
export const counterLabel = (body) => {
|
|
862
|
+
const counts = segmentCount(body);
|
|
863
|
+
const segments = Math.max(counts.segments, 1);
|
|
864
|
+
const capacity = segments * counts.perSegment;
|
|
865
|
+
|
|
866
|
+
return `${counts.characters} / ${capacity} · ${segments} SMS · ${counts.encoding}`;
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Which messages end a run from one side.
|
|
871
|
+
*
|
|
872
|
+
* Consecutive bubbles from the same direction read as one utterance, so they
|
|
873
|
+
* sit tighter and only the last of them carries a timestamp — six identical
|
|
874
|
+
* times stacked down the margin is noise. Pure, so the timeline can decide
|
|
875
|
+
* this once per day group rather than peering at its neighbours mid-render.
|
|
876
|
+
*/
|
|
877
|
+
export const markRuns = (messages) => {
|
|
878
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
879
|
+
|
|
880
|
+
return list.map((message, index) => {
|
|
881
|
+
const next = list[index + 1];
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
message,
|
|
885
|
+
firstOfRun: index === 0 || list[index - 1].direction !== message.direction,
|
|
886
|
+
lastOfRun: !next || next.direction !== message.direction,
|
|
887
|
+
};
|
|
888
|
+
});
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* A line, as the selector lists it: `Reception · 3 unread`.
|
|
893
|
+
*
|
|
894
|
+
* "(3)" beside a name is a count of something unstated. The word is four
|
|
895
|
+
* characters and removes the question.
|
|
896
|
+
*/
|
|
897
|
+
export const lineOptionLabel = (line, unread = 0) => {
|
|
898
|
+
// The label alone: the number lives in the page header (lineNumberForDisplay),
|
|
899
|
+
// where it can be read without opening the picker.
|
|
900
|
+
const name = line?.label || lineNumberForDisplay(line) || 'Line';
|
|
901
|
+
const count = Number(unread) || 0;
|
|
902
|
+
|
|
903
|
+
return count > 0 ? `${name} · ${count} unread` : name;
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* The line's number the way it is read out to a client - "0468 193 536" -
|
|
908
|
+
* or '' when the line has none.
|
|
909
|
+
*/
|
|
910
|
+
export const lineNumberForDisplay = (line) =>
|
|
911
|
+
line?.display_number || normaliseNumberForDisplay(line?.phone_number) || '';
|
|
912
|
+
|
|
913
|
+
/** True while the composer should warn that nothing is actually going out. */
|
|
914
|
+
export const isHeldTransport = (status) =>
|
|
915
|
+
!status || status.transport === 'null' || status.connected === false;
|
|
916
|
+
|
|
917
|
+
/* --------------------------------------------------------------- recipients */
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* An Australian mobile in E.164: `+614` and eight more digits. Nothing else in
|
|
921
|
+
* the +61 space can receive an SMS reliably - a landline gets a text-to-speech
|
|
922
|
+
* call from some carriers and silence from the rest, and 13/1300/1800 numbers
|
|
923
|
+
* cannot receive at all.
|
|
924
|
+
*/
|
|
925
|
+
const AU_MOBILE = /^\+614\d{8}$/;
|
|
926
|
+
|
|
927
|
+
/** The sentences the compose box says about a number, in one place. */
|
|
928
|
+
export const RECIPIENT_HINTS = {
|
|
929
|
+
invalid: 'Enter an Australian mobile like 0412 345 678, or pick a client',
|
|
930
|
+
landline: 'That looks like a landline; SMS needs a mobile number',
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Below this many digits the field is still being typed, not wrong.
|
|
935
|
+
*
|
|
936
|
+
* An Australian number is ten digits; judging at the sixth turns the hint red
|
|
937
|
+
* halfway through every number anyone enters. Eight is late enough to be quiet
|
|
938
|
+
* while typing and early enough to catch the case that matters - an eight-digit
|
|
939
|
+
* local number with no area code.
|
|
940
|
+
*/
|
|
941
|
+
const JUDGE_AT_DIGITS = 8;
|
|
942
|
+
|
|
943
|
+
/** Is this an attempt at a number at all, rather than a name being searched? */
|
|
944
|
+
const looksNumeric = (value) => {
|
|
945
|
+
const raw = String(value ?? '').trim();
|
|
946
|
+
|
|
947
|
+
if (raw === '') return false;
|
|
948
|
+
|
|
949
|
+
return /^[+(]?[\d\s()+\-.]*$/.test(raw);
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* What the compose box should say about what has been typed into "To".
|
|
954
|
+
*
|
|
955
|
+
* Three questions at once, because the answer to each depends on the one
|
|
956
|
+
* before it: is this a number or a name being searched, does it normalise, and
|
|
957
|
+
* is what it normalises to something an SMS can actually reach.
|
|
958
|
+
*
|
|
959
|
+
* kind 'none' nothing to say - blank, a name, or a number mid-typing
|
|
960
|
+
* kind 'mobile' good to send; `message` names the number it will go to
|
|
961
|
+
* kind 'landline' an AU number, but not one that receives SMS
|
|
962
|
+
* kind 'invalid' digits that do not make a number we can send to
|
|
963
|
+
*
|
|
964
|
+
* Overseas numbers are only accepted when they were typed as international -
|
|
965
|
+
* a leading `+` or an `00`/`0011` dial prefix. Without that, a bare run of
|
|
966
|
+
* digits is read as Australian (that is what `toE164` does with it), and
|
|
967
|
+
* quietly sending "415 555 2671" to +61415552671 is exactly the mistake this
|
|
968
|
+
* function exists to prevent. There is no attempt to tell a French mobile from
|
|
969
|
+
* a French landline: that needs the metadata blob this module deliberately
|
|
970
|
+
* does not carry, so an international number is taken on trust.
|
|
971
|
+
*
|
|
972
|
+
* @param {string} input What is in the field.
|
|
973
|
+
* @returns {{e164: ?string, kind: string, ok: boolean, message: string}}
|
|
974
|
+
*/
|
|
975
|
+
export const describeRecipient = (input) => {
|
|
976
|
+
const raw = String(input ?? '').trim();
|
|
977
|
+
const nothingToSay = { e164: null, kind: 'none', ok: false, message: '' };
|
|
978
|
+
|
|
979
|
+
if (raw === '' || !looksNumeric(raw)) return nothingToSay;
|
|
980
|
+
|
|
981
|
+
const digits = raw.replace(/\D+/g, '');
|
|
982
|
+
// `0011 61 4…` is how an Australian dials out; `toE164` does not know the
|
|
983
|
+
// prefix, so it is turned into the `+` it means before anything else looks
|
|
984
|
+
// at it. Without this the IDD is swallowed into the country code.
|
|
985
|
+
const idd = ['0011', '011', '00'].find(
|
|
986
|
+
(prefix) =>
|
|
987
|
+
digits.startsWith(prefix) && digits.length > prefix.length + 6
|
|
988
|
+
);
|
|
989
|
+
let dialled = idd === undefined ? raw : `+${digits.slice(idd.length)}`;
|
|
990
|
+
|
|
991
|
+
// "61422442886" - the country code typed without its plus. Much of the
|
|
992
|
+
// practice's own contact data is stored that way, so it is pasted that way.
|
|
993
|
+
if (idd === undefined && !raw.trim().startsWith('+') && /^614\d{8}$/.test(digits)) {
|
|
994
|
+
dialled = `+${digits}`;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const international = dialled.trim().startsWith('+');
|
|
998
|
+
|
|
999
|
+
if (digits.length < JUDGE_AT_DIGITS && !international) return nothingToSay;
|
|
1000
|
+
|
|
1001
|
+
const e164 = toE164(dialled);
|
|
1002
|
+
|
|
1003
|
+
if (e164 === null) {
|
|
1004
|
+
return { e164: null, kind: 'invalid', ok: false, message: RECIPIENT_HINTS.invalid };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (e164.startsWith('+61')) {
|
|
1008
|
+
// A number that only reached +61 because `toE164` defaults there, when
|
|
1009
|
+
// what was typed was meant to be foreign, is not a number we can send.
|
|
1010
|
+
if (AU_MOBILE.test(e164)) {
|
|
1011
|
+
return {
|
|
1012
|
+
e164,
|
|
1013
|
+
kind: 'mobile',
|
|
1014
|
+
ok: true,
|
|
1015
|
+
message: `Will send to ${normaliseNumberForDisplay(e164)}`,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
return {
|
|
1020
|
+
e164,
|
|
1021
|
+
kind: 'landline',
|
|
1022
|
+
ok: false,
|
|
1023
|
+
message: RECIPIENT_HINTS.landline,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Anything outside +61 has to have been typed as international, and has to
|
|
1028
|
+
// be a length E.164 allows.
|
|
1029
|
+
const body = e164.replace(/^\+/, '');
|
|
1030
|
+
|
|
1031
|
+
if (!international || body.length < 8 || body.length > 15) {
|
|
1032
|
+
return { e164: null, kind: 'invalid', ok: false, message: RECIPIENT_HINTS.invalid };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
return {
|
|
1036
|
+
e164,
|
|
1037
|
+
kind: 'mobile',
|
|
1038
|
+
ok: true,
|
|
1039
|
+
message: `Will send to ${normaliseNumberForDisplay(e164)}`,
|
|
1040
|
+
};
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
export default {
|
|
1044
|
+
badgeCount,
|
|
1045
|
+
clockTime,
|
|
1046
|
+
composerPlaceholder,
|
|
1047
|
+
counterLabel,
|
|
1048
|
+
lineOptionLabel,
|
|
1049
|
+
markRuns,
|
|
1050
|
+
dayLabel,
|
|
1051
|
+
describeRecipient,
|
|
1052
|
+
describeTransport,
|
|
1053
|
+
fillTemplate,
|
|
1054
|
+
groupMessagesByDay,
|
|
1055
|
+
initialsFor,
|
|
1056
|
+
isHeldTransport,
|
|
1057
|
+
looksLikeNumber,
|
|
1058
|
+
messageTimestamp,
|
|
1059
|
+
nextPlaceholder,
|
|
1060
|
+
normaliseNumberForDisplay,
|
|
1061
|
+
parseDate,
|
|
1062
|
+
prependMessages,
|
|
1063
|
+
RECIPIENT_HINTS,
|
|
1064
|
+
relativeTime,
|
|
1065
|
+
segmentCount,
|
|
1066
|
+
splitPersonName,
|
|
1067
|
+
statusLabel,
|
|
1068
|
+
sumUnread,
|
|
1069
|
+
threadDisplayName,
|
|
1070
|
+
threadIdFromSearch,
|
|
1071
|
+
threadSubtitle,
|
|
1072
|
+
toE164,
|
|
1073
|
+
upsertMessage,
|
|
1074
|
+
upsertThread,
|
|
1075
|
+
};
|