@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
|
@@ -66,10 +66,10 @@ export const buildFieldSources = (model, entityId, addedRelations) => {
|
|
|
66
66
|
* only offered where the contract allows them.
|
|
67
67
|
*/
|
|
68
68
|
const SemanticFieldsStep = ({
|
|
69
|
-
model,
|
|
70
|
-
entityId,
|
|
71
|
-
addedRelations,
|
|
72
|
-
selections,
|
|
69
|
+
model = null,
|
|
70
|
+
entityId = '',
|
|
71
|
+
addedRelations = [],
|
|
72
|
+
selections = [],
|
|
73
73
|
onToggleField,
|
|
74
74
|
onChangeAggregate,
|
|
75
75
|
onRemoveSelection,
|
|
@@ -327,11 +327,4 @@ SemanticFieldsStep.propTypes = {
|
|
|
327
327
|
onMoveSelection: PropTypes.func.isRequired,
|
|
328
328
|
};
|
|
329
329
|
|
|
330
|
-
SemanticFieldsStep.defaultProps = {
|
|
331
|
-
model: null,
|
|
332
|
-
entityId: '',
|
|
333
|
-
addedRelations: [],
|
|
334
|
-
selections: [],
|
|
335
|
-
};
|
|
336
|
-
|
|
337
330
|
export default SemanticFieldsStep;
|
|
@@ -26,8 +26,8 @@ export const buildFilterFieldOptions = (model, entityId, addedRelations) =>
|
|
|
26
26
|
}));
|
|
27
27
|
|
|
28
28
|
const SemanticFilterCondition = ({
|
|
29
|
-
model,
|
|
30
|
-
entityId,
|
|
29
|
+
model = null,
|
|
30
|
+
entityId = '',
|
|
31
31
|
condition,
|
|
32
32
|
fieldGroups,
|
|
33
33
|
onChange,
|
|
@@ -231,17 +231,12 @@ SemanticFilterCondition.propTypes = {
|
|
|
231
231
|
onRemove: PropTypes.func.isRequired,
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
-
SemanticFilterCondition.defaultProps = {
|
|
235
|
-
model: null,
|
|
236
|
-
entityId: '',
|
|
237
|
-
};
|
|
238
|
-
|
|
239
234
|
const SemanticFilterGroup = ({
|
|
240
|
-
model,
|
|
241
|
-
entityId,
|
|
235
|
+
model = null,
|
|
236
|
+
entityId = '',
|
|
242
237
|
group,
|
|
243
238
|
fieldGroups,
|
|
244
|
-
depth,
|
|
239
|
+
depth = 0,
|
|
245
240
|
onChangeNode,
|
|
246
241
|
onRemoveNode,
|
|
247
242
|
onAddCondition,
|
|
@@ -355,12 +350,6 @@ SemanticFilterGroup.propTypes = {
|
|
|
355
350
|
onAddGroup: PropTypes.func.isRequired,
|
|
356
351
|
};
|
|
357
352
|
|
|
358
|
-
SemanticFilterGroup.defaultProps = {
|
|
359
|
-
model: null,
|
|
360
|
-
entityId: '',
|
|
361
|
-
depth: 0,
|
|
362
|
-
};
|
|
363
|
-
|
|
364
353
|
/**
|
|
365
354
|
* Step 4 in semantic mode: build the filter tree.
|
|
366
355
|
*
|
|
@@ -369,11 +358,11 @@ SemanticFilterGroup.defaultProps = {
|
|
|
369
358
|
* become runtime parameters, whose labels can be edited below the tree.
|
|
370
359
|
*/
|
|
371
360
|
const SemanticFiltersStep = ({
|
|
372
|
-
model,
|
|
373
|
-
entityId,
|
|
374
|
-
addedRelations,
|
|
361
|
+
model = null,
|
|
362
|
+
entityId = '',
|
|
363
|
+
addedRelations = [],
|
|
375
364
|
filterTree,
|
|
376
|
-
parameters,
|
|
365
|
+
parameters = [],
|
|
377
366
|
onChangeNode,
|
|
378
367
|
onRemoveNode,
|
|
379
368
|
onAddCondition,
|
|
@@ -473,11 +462,4 @@ SemanticFiltersStep.propTypes = {
|
|
|
473
462
|
onChangeParameter: PropTypes.func.isRequired,
|
|
474
463
|
};
|
|
475
464
|
|
|
476
|
-
SemanticFiltersStep.defaultProps = {
|
|
477
|
-
model: null,
|
|
478
|
-
entityId: '',
|
|
479
|
-
addedRelations: [],
|
|
480
|
-
parameters: [],
|
|
481
|
-
};
|
|
482
|
-
|
|
483
465
|
export default SemanticFiltersStep;
|
|
@@ -12,11 +12,11 @@ import styles from '../../styles/ReportSemantic.module.scss';
|
|
|
12
12
|
* excluded for the same reason: they are the summary, not the grouping key.
|
|
13
13
|
*/
|
|
14
14
|
const SemanticGroupingStep = ({
|
|
15
|
-
model,
|
|
16
|
-
entityId,
|
|
17
|
-
selections,
|
|
18
|
-
groupBy,
|
|
19
|
-
sort,
|
|
15
|
+
model = null,
|
|
16
|
+
entityId = '',
|
|
17
|
+
selections = [],
|
|
18
|
+
groupBy = [],
|
|
19
|
+
sort = [],
|
|
20
20
|
onToggleGroupBy,
|
|
21
21
|
onAddSort,
|
|
22
22
|
onChangeSort,
|
|
@@ -191,12 +191,4 @@ SemanticGroupingStep.propTypes = {
|
|
|
191
191
|
onRemoveSort: PropTypes.func.isRequired,
|
|
192
192
|
};
|
|
193
193
|
|
|
194
|
-
SemanticGroupingStep.defaultProps = {
|
|
195
|
-
model: null,
|
|
196
|
-
entityId: '',
|
|
197
|
-
selections: [],
|
|
198
|
-
groupBy: [],
|
|
199
|
-
sort: [],
|
|
200
|
-
};
|
|
201
|
-
|
|
202
194
|
export default SemanticGroupingStep;
|
|
@@ -44,11 +44,11 @@ const slotType = (parameterType) => {
|
|
|
44
44
|
* scalar.
|
|
45
45
|
*/
|
|
46
46
|
const SemanticParameterPrompt = ({
|
|
47
|
-
model,
|
|
48
|
-
entityId,
|
|
49
|
-
filterTree,
|
|
50
|
-
parameters,
|
|
51
|
-
values,
|
|
47
|
+
model = null,
|
|
48
|
+
entityId = '',
|
|
49
|
+
filterTree = null,
|
|
50
|
+
parameters = [],
|
|
51
|
+
values = {},
|
|
52
52
|
onChange,
|
|
53
53
|
}) => {
|
|
54
54
|
if (!parameters || parameters.length === 0) return null;
|
|
@@ -146,12 +146,4 @@ SemanticParameterPrompt.propTypes = {
|
|
|
146
146
|
onChange: PropTypes.func.isRequired,
|
|
147
147
|
};
|
|
148
148
|
|
|
149
|
-
SemanticParameterPrompt.defaultProps = {
|
|
150
|
-
model: null,
|
|
151
|
-
entityId: '',
|
|
152
|
-
filterTree: null,
|
|
153
|
-
parameters: [],
|
|
154
|
-
values: {},
|
|
155
|
-
};
|
|
156
|
-
|
|
157
149
|
export default SemanticParameterPrompt;
|
|
@@ -69,26 +69,26 @@ const buildGroups = (rows, columns, groupKey) => {
|
|
|
69
69
|
* review the results before saving or exporting.
|
|
70
70
|
*/
|
|
71
71
|
const SemanticPreviewStep = ({
|
|
72
|
-
model,
|
|
73
|
-
entityId,
|
|
74
|
-
selections,
|
|
75
|
-
filterTree,
|
|
76
|
-
parameters,
|
|
77
|
-
parameterValues,
|
|
78
|
-
groupBy,
|
|
79
|
-
rows,
|
|
80
|
-
total,
|
|
81
|
-
page,
|
|
82
|
-
pageSize,
|
|
83
|
-
isLoading,
|
|
84
|
-
error,
|
|
72
|
+
model = null,
|
|
73
|
+
entityId = '',
|
|
74
|
+
selections = [],
|
|
75
|
+
filterTree = null,
|
|
76
|
+
parameters = [],
|
|
77
|
+
parameterValues = {},
|
|
78
|
+
groupBy = [],
|
|
79
|
+
rows = [],
|
|
80
|
+
total = 0,
|
|
81
|
+
page = 1,
|
|
82
|
+
pageSize = 25,
|
|
83
|
+
isLoading = false,
|
|
84
|
+
error = '',
|
|
85
85
|
onChangeParameterValue,
|
|
86
86
|
onRun,
|
|
87
87
|
onChangePage,
|
|
88
88
|
onChangePageSize,
|
|
89
89
|
onExport,
|
|
90
90
|
onSave,
|
|
91
|
-
canSave,
|
|
91
|
+
canSave = false,
|
|
92
92
|
}) => {
|
|
93
93
|
const columns = useMemo(
|
|
94
94
|
() => buildPreviewColumns(model, entityId, selections),
|
|
@@ -332,21 +332,4 @@ SemanticPreviewStep.propTypes = {
|
|
|
332
332
|
canSave: PropTypes.bool,
|
|
333
333
|
};
|
|
334
334
|
|
|
335
|
-
SemanticPreviewStep.defaultProps = {
|
|
336
|
-
model: null,
|
|
337
|
-
entityId: '',
|
|
338
|
-
selections: [],
|
|
339
|
-
filterTree: null,
|
|
340
|
-
parameters: [],
|
|
341
|
-
parameterValues: {},
|
|
342
|
-
groupBy: [],
|
|
343
|
-
rows: [],
|
|
344
|
-
total: 0,
|
|
345
|
-
page: 1,
|
|
346
|
-
pageSize: 25,
|
|
347
|
-
isLoading: false,
|
|
348
|
-
error: '',
|
|
349
|
-
canSave: false,
|
|
350
|
-
};
|
|
351
|
-
|
|
352
335
|
export default SemanticPreviewStep;
|
|
@@ -48,9 +48,9 @@ export const availableRelationPaths = (model, entityId, addedPaths) => {
|
|
|
48
48
|
* whether they want it.
|
|
49
49
|
*/
|
|
50
50
|
const SemanticRelationsStep = ({
|
|
51
|
-
model,
|
|
52
|
-
entityId,
|
|
53
|
-
addedRelations,
|
|
51
|
+
model = null,
|
|
52
|
+
entityId = '',
|
|
53
|
+
addedRelations = [],
|
|
54
54
|
onToggle,
|
|
55
55
|
}) => {
|
|
56
56
|
const options = availableRelationPaths(model, entityId, addedRelations);
|
|
@@ -145,10 +145,4 @@ SemanticRelationsStep.propTypes = {
|
|
|
145
145
|
onToggle: PropTypes.func.isRequired,
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
-
SemanticRelationsStep.defaultProps = {
|
|
149
|
-
model: null,
|
|
150
|
-
entityId: '',
|
|
151
|
-
addedRelations: [],
|
|
152
|
-
};
|
|
153
|
-
|
|
154
148
|
export default SemanticRelationsStep;
|
|
@@ -12,7 +12,7 @@ const NUMERIC_TYPES = ['number', 'money', 'percent'];
|
|
|
12
12
|
* boolean -> yes/no select, money/number/percent -> numeric input,
|
|
13
13
|
* everything else -> text.
|
|
14
14
|
*/
|
|
15
|
-
const SemanticValueInput = ({ type, field, value, onChange, ariaLabel }) => {
|
|
15
|
+
const SemanticValueInput = ({ type = 'text', field = null, value = '', onChange, ariaLabel = 'Value' }) => {
|
|
16
16
|
const commonProps = {
|
|
17
17
|
className: styles.input,
|
|
18
18
|
'aria-label': ariaLabel,
|
|
@@ -77,19 +77,12 @@ SemanticValueInput.propTypes = {
|
|
|
77
77
|
ariaLabel: PropTypes.string,
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
-
SemanticValueInput.defaultProps = {
|
|
81
|
-
type: 'text',
|
|
82
|
-
field: null,
|
|
83
|
-
value: '',
|
|
84
|
-
ariaLabel: 'Value',
|
|
85
|
-
};
|
|
86
|
-
|
|
87
80
|
/**
|
|
88
81
|
* Multi-value editor for the `in` / `not_in` operators. Enum fields get a
|
|
89
82
|
* checkbox list of declared values; everything else gets a comma-separated
|
|
90
83
|
* text box, which is the least surprising control for a free-text list.
|
|
91
84
|
*/
|
|
92
|
-
export const SemanticValueListInput = ({ type, field, values, onChange }) => {
|
|
85
|
+
export const SemanticValueListInput = ({ type = 'text', field = null, values = [], onChange }) => {
|
|
93
86
|
const selected = Array.isArray(values) ? values : [];
|
|
94
87
|
|
|
95
88
|
if (type === 'enum') {
|
|
@@ -144,10 +137,4 @@ SemanticValueListInput.propTypes = {
|
|
|
144
137
|
onChange: PropTypes.func.isRequired,
|
|
145
138
|
};
|
|
146
139
|
|
|
147
|
-
SemanticValueListInput.defaultProps = {
|
|
148
|
-
type: 'text',
|
|
149
|
-
field: null,
|
|
150
|
-
values: [],
|
|
151
|
-
};
|
|
152
|
-
|
|
153
140
|
export default SemanticValueInput;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which top-level nav item is the current page.
|
|
3
|
+
*
|
|
4
|
+
* Pulled out of Navigation's `updateNavItems` effect because it was two
|
|
5
|
+
* different answers depending on how many segments the URL happened to have,
|
|
6
|
+
* and neither of them was right:
|
|
7
|
+
*
|
|
8
|
+
* - Below three segments it asked `currentUrl.includes(navUrl)`, which is a
|
|
9
|
+
* substring test on a path. `/tasks` matched `/tasks-archive`, and every
|
|
10
|
+
* item whose url is a substring of another item's lit up together.
|
|
11
|
+
* - It only looked at an item's CHILDREN in the three-or-more-segment
|
|
12
|
+
* branch. So standing on `/reports` — one segment — the Admin item, whose
|
|
13
|
+
* own url is `/settings` and which owns `/reports` as a child, tested
|
|
14
|
+
* `'/reports'.includes('/settings')` and came back inactive. The bar had
|
|
15
|
+
* no current page at all on most of the pages it links to.
|
|
16
|
+
*
|
|
17
|
+
* One rule instead, at every depth: a path matches when it IS the item's path
|
|
18
|
+
* or sits under it as a whole segment, and an item is active when it matches
|
|
19
|
+
* itself or any of its children.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const cleanUrl = (url) => {
|
|
23
|
+
if (!url) {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
return url.startsWith('/') ? url : `/${url}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Segment-prefix match: `/tasks` covers `/tasks` and `/tasks/12`, and does
|
|
31
|
+
* NOT cover `/tasks-archive`. `/` is excluded deliberately — as a prefix it
|
|
32
|
+
* covers the entire application, so the dashboard is matched exactly instead.
|
|
33
|
+
*/
|
|
34
|
+
const covers = (base, current) =>
|
|
35
|
+
!!base &&
|
|
36
|
+
base !== '/' &&
|
|
37
|
+
(current === base || current.startsWith(`${base}/`));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} navUrl the item's own url ('' for a pure parent).
|
|
41
|
+
* @param {string[]} childUrls the urls of its dropdown children, if any.
|
|
42
|
+
* @param {string} currentUrl the path being viewed.
|
|
43
|
+
*/
|
|
44
|
+
export function isNavActive(navUrl, childUrls, currentUrl) {
|
|
45
|
+
const current = cleanUrl(currentUrl) || '/';
|
|
46
|
+
const base = cleanUrl(navUrl);
|
|
47
|
+
|
|
48
|
+
// The dashboard, which is reachable under both of its names.
|
|
49
|
+
if (base === '/') {
|
|
50
|
+
return current === '/' || current === '/dashboard';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (covers(base, current)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (childUrls || []).some((child) => covers(cleanUrl(child), current));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default isNavActive;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop alerts — the browser's own Notification API, nothing more.
|
|
3
|
+
*
|
|
4
|
+
* No service worker and no push subscription: this raises an OS-level toast
|
|
5
|
+
* only while a tab of the app is open, which is exactly the scope that was
|
|
6
|
+
* asked for. Staff who have closed the CRM get nothing, and that is the
|
|
7
|
+
* honest behaviour — promising more would need a push service, a VAPID key
|
|
8
|
+
* pair and a server that survives the tab being gone.
|
|
9
|
+
*
|
|
10
|
+
* Three gates, and all three must open before anything appears:
|
|
11
|
+
*
|
|
12
|
+
* supported the API exists (it does not on iOS Safari outside a
|
|
13
|
+
* home-screen app, and not in an insecure context);
|
|
14
|
+
* permission the user granted it, which only a click can ask for;
|
|
15
|
+
* enabled the user has not switched it off in the bell's dropdown.
|
|
16
|
+
*
|
|
17
|
+
* The fourth is subtler and matters more day to day: if the tab is already
|
|
18
|
+
* visible AND focused, nothing is raised. The in-app UI — the bell badge, the
|
|
19
|
+
* messages popover — is already showing the same thing, and an OS toast on
|
|
20
|
+
* top of it is noise. `force: true` overrides that for the rare caller that
|
|
21
|
+
* genuinely wants both.
|
|
22
|
+
*
|
|
23
|
+
* Everything here is a plain function over globals so it can be tested with a
|
|
24
|
+
* fake `Notification`, `document` and `localStorage` (tests/notify.test.mjs)
|
|
25
|
+
* — the same arrangement as the sms and vault helpers.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Where the user's on/off choice lives. Shared by the bell and the SMS badge. */
|
|
29
|
+
export const STORAGE_KEY = 'visns.desktopAlerts';
|
|
30
|
+
|
|
31
|
+
/** How long a toast lingers before it closes itself. */
|
|
32
|
+
export const AUTO_CLOSE_MS = 8000;
|
|
33
|
+
|
|
34
|
+
const ON = 'on';
|
|
35
|
+
const OFF = 'off';
|
|
36
|
+
|
|
37
|
+
/** The Notification constructor, or null. Read late — tests swap it in. */
|
|
38
|
+
const constructorRef = () => {
|
|
39
|
+
const ctor = globalThis?.Notification;
|
|
40
|
+
|
|
41
|
+
return typeof ctor === 'function' ? ctor : null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const doc = () => (typeof globalThis?.document === 'undefined' ? null : globalThis.document);
|
|
45
|
+
|
|
46
|
+
const storage = () => {
|
|
47
|
+
try {
|
|
48
|
+
return globalThis?.localStorage ?? null;
|
|
49
|
+
} catch {
|
|
50
|
+
// Reading localStorage throws outright in some privacy modes.
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Is the browser capable of this at all? */
|
|
56
|
+
export const isSupported = () => constructorRef() !== null;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 'granted' | 'denied' | 'default'.
|
|
60
|
+
*
|
|
61
|
+
* A browser that cannot do this is reported as 'denied' rather than some
|
|
62
|
+
* fourth value: every caller wants to know "can I show one?", and a fourth
|
|
63
|
+
* state only invites a missing branch. `isSupported()` distinguishes the two
|
|
64
|
+
* when the UI needs to explain itself.
|
|
65
|
+
*/
|
|
66
|
+
export const getPermission = () => {
|
|
67
|
+
const ctor = constructorRef();
|
|
68
|
+
|
|
69
|
+
if (!ctor) return 'denied';
|
|
70
|
+
|
|
71
|
+
const permission = ctor.permission;
|
|
72
|
+
|
|
73
|
+
return permission === 'granted' || permission === 'denied' ? permission : 'default';
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Ask. Must be called from a user gesture or browsers ignore it.
|
|
78
|
+
*
|
|
79
|
+
* Resolves rather than rejects, and copes with the old callback signature
|
|
80
|
+
* Safari shipped for years.
|
|
81
|
+
*
|
|
82
|
+
* @returns {Promise<'granted'|'denied'|'default'>}
|
|
83
|
+
*/
|
|
84
|
+
export const requestPermission = () => {
|
|
85
|
+
const ctor = constructorRef();
|
|
86
|
+
|
|
87
|
+
if (!ctor) return Promise.resolve('denied');
|
|
88
|
+
if (ctor.permission === 'granted' || ctor.permission === 'denied') {
|
|
89
|
+
return Promise.resolve(ctor.permission);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
try {
|
|
94
|
+
const result = ctor.requestPermission((legacy) => resolve(legacy || 'default'));
|
|
95
|
+
|
|
96
|
+
if (result && typeof result.then === 'function') {
|
|
97
|
+
result.then((value) => resolve(value || 'default')).catch(() => resolve('denied'));
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
resolve('denied');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The user's switch.
|
|
107
|
+
*
|
|
108
|
+
* Untouched means "on once permission is granted": someone who has just been
|
|
109
|
+
* through the browser's permission prompt has said yes twice already, and
|
|
110
|
+
* making them find a second toggle afterwards would be a trap.
|
|
111
|
+
*/
|
|
112
|
+
export const isEnabled = () => {
|
|
113
|
+
const stored = (() => {
|
|
114
|
+
try {
|
|
115
|
+
return storage()?.getItem(STORAGE_KEY) ?? null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
|
|
121
|
+
if (stored === ON) return true;
|
|
122
|
+
if (stored === OFF) return false;
|
|
123
|
+
|
|
124
|
+
return getPermission() === 'granted';
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Persist the switch. Silent if storage is unavailable — it is a preference. */
|
|
128
|
+
export const setEnabled = (enabled) => {
|
|
129
|
+
try {
|
|
130
|
+
storage()?.setItem(STORAGE_KEY, enabled ? ON : OFF);
|
|
131
|
+
} catch {
|
|
132
|
+
/* private mode, quota, a locked-down iframe — none worth a toast */
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return !!enabled;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Is the user already looking at us? */
|
|
139
|
+
const tabHasTheUser = () => {
|
|
140
|
+
const d = doc();
|
|
141
|
+
|
|
142
|
+
if (!d) return false;
|
|
143
|
+
|
|
144
|
+
const visible = d.visibilityState === 'visible';
|
|
145
|
+
const focused = typeof d.hasFocus === 'function' ? d.hasFocus() : false;
|
|
146
|
+
|
|
147
|
+
return visible && focused;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Raise one desktop alert.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} options
|
|
154
|
+
* @param {string} options.title The bold line. Required.
|
|
155
|
+
* @param {string} [options.body] One or two lines under it.
|
|
156
|
+
* @param {string} [options.tag] Replaces an earlier toast with the same
|
|
157
|
+
* tag instead of stacking — pass a stable
|
|
158
|
+
* per-subject id ('sms-14', 'notification-…').
|
|
159
|
+
* @param {string} [options.icon] Icon URL.
|
|
160
|
+
* @param {string} [options.url] Where a click should land.
|
|
161
|
+
* @param {Function} [options.onClick] Overrides `url` entirely.
|
|
162
|
+
* @param {Function} [options.navigate] React Router's navigate, when there is one.
|
|
163
|
+
* @param {boolean} [options.force] Show even if the tab is focused.
|
|
164
|
+
*
|
|
165
|
+
* @returns {Notification|null} The toast, or null if any gate was closed.
|
|
166
|
+
*/
|
|
167
|
+
export const notify = ({
|
|
168
|
+
title,
|
|
169
|
+
body,
|
|
170
|
+
tag,
|
|
171
|
+
icon,
|
|
172
|
+
url,
|
|
173
|
+
onClick,
|
|
174
|
+
navigate,
|
|
175
|
+
force = false,
|
|
176
|
+
} = {}) => {
|
|
177
|
+
const ctor = constructorRef();
|
|
178
|
+
|
|
179
|
+
if (!ctor || !title) return null;
|
|
180
|
+
if (getPermission() !== 'granted') return null;
|
|
181
|
+
if (!isEnabled()) return null;
|
|
182
|
+
if (!force && tabHasTheUser()) return null;
|
|
183
|
+
|
|
184
|
+
let toast;
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
// Throws, rather than returning null, in an insecure context and in a
|
|
188
|
+
// few embedded webviews.
|
|
189
|
+
toast = new ctor(title, {
|
|
190
|
+
body,
|
|
191
|
+
tag,
|
|
192
|
+
icon,
|
|
193
|
+
// The tag already collapses duplicates; renotify would re-buzz for
|
|
194
|
+
// each one, which is how a busy inbox becomes a reason to turn the
|
|
195
|
+
// whole feature off.
|
|
196
|
+
renotify: false,
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
toast.onclick = () => {
|
|
203
|
+
try {
|
|
204
|
+
globalThis?.focus?.();
|
|
205
|
+
} catch {
|
|
206
|
+
/* a background tab is not always allowed to raise itself */
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
toast.close();
|
|
211
|
+
} catch {
|
|
212
|
+
/* already gone */
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (typeof onClick === 'function') {
|
|
216
|
+
onClick();
|
|
217
|
+
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!url) return;
|
|
222
|
+
|
|
223
|
+
if (typeof navigate === 'function') {
|
|
224
|
+
navigate(url);
|
|
225
|
+
} else {
|
|
226
|
+
globalThis?.location?.assign?.(url);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Chrome on Windows keeps a toast in the tray until it is dismissed; eight
|
|
231
|
+
// seconds is long enough to read and short enough not to pile up.
|
|
232
|
+
try {
|
|
233
|
+
globalThis?.setTimeout?.(() => {
|
|
234
|
+
try {
|
|
235
|
+
toast.close();
|
|
236
|
+
} catch {
|
|
237
|
+
/* already gone */
|
|
238
|
+
}
|
|
239
|
+
}, AUTO_CLOSE_MS);
|
|
240
|
+
} catch {
|
|
241
|
+
/* no timer host: the toast simply stays until dismissed */
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return toast;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export default {
|
|
248
|
+
AUTO_CLOSE_MS,
|
|
249
|
+
STORAGE_KEY,
|
|
250
|
+
getPermission,
|
|
251
|
+
isEnabled,
|
|
252
|
+
isSupported,
|
|
253
|
+
notify,
|
|
254
|
+
requestPermission,
|
|
255
|
+
setEnabled,
|
|
256
|
+
};
|
|
@@ -380,10 +380,20 @@ class SketchField extends PureComponent {
|
|
|
380
380
|
return (
|
|
381
381
|
<div
|
|
382
382
|
className={className}
|
|
383
|
-
|
|
383
|
+
// Block bodies, not expression bodies. React 19 treats a
|
|
384
|
+
// callback ref's return value as a cleanup function, so
|
|
385
|
+
// `ref={c => (this._x = c)}` — which returns the assigned
|
|
386
|
+
// element — hands React a non-function to call on unmount.
|
|
387
|
+
ref={(c) => {
|
|
388
|
+
this._container = c;
|
|
389
|
+
}}
|
|
384
390
|
style={canvasDivStyle}
|
|
385
391
|
>
|
|
386
|
-
<canvas
|
|
392
|
+
<canvas
|
|
393
|
+
ref={(c) => {
|
|
394
|
+
this._canvas = c;
|
|
395
|
+
}}
|
|
396
|
+
>
|
|
387
397
|
Sorry, Canvas HTML5 element is not supported by your
|
|
388
398
|
browser.
|
|
389
399
|
</canvas>
|