@visns-studio/visns-components 6.28.0 → 6.31.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 +123 -0
- package/package.json +1 -1
- package/src/components/callQueue/CallQueueDiagnostics.jsx +13 -0
- package/src/components/callQueue/CallQueuePop.jsx +308 -43
- package/src/components/callQueue/callPopStatus.js +23 -0
- package/src/components/callQueue/callQueueHelpers.js +66 -0
- package/src/components/echo/sharedPrivateChannel.js +217 -0
- package/src/components/phone/ZoomPhoneBadge.jsx +5 -0
- package/src/components/phone/phonePresenceHelpers.js +31 -0
- package/src/components/phone/useZoomPhoneLive.js +147 -7
- package/src/components/styles/TicketConversation.module.scss +22 -1
- package/src/components/tickets/TicketConversation.jsx +96 -5
- package/src/index.js +15 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared private channel registry.
|
|
3
|
+
* -----------------------------------------------------------------------
|
|
4
|
+
* Ref-counting in front of `echo.private(name)` / `echo.leave(name)`, for the
|
|
5
|
+
* case where two independent components subscribe to the SAME private channel.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: laravel-echo's `leave(name)` is not "stop listening for me",
|
|
8
|
+
* it is "destroy this channel" — the connector does
|
|
9
|
+
*
|
|
10
|
+
* this.channels[name].unsubscribe(); delete this.channels[name];
|
|
11
|
+
*
|
|
12
|
+
* so the FIRST component to unmount takes the channel away from everyone still
|
|
13
|
+
* using it. That is exactly what happened with `call-queue-monitor.{env}`: the
|
|
14
|
+
* CRM header hides itself on client and task record pages, the header's phone
|
|
15
|
+
* badge unmounted and called `leave()`, and the call pop — mounted at the app
|
|
16
|
+
* root, with stable effect deps, so it never resubscribed — silently stopped
|
|
17
|
+
* receiving calls while still reporting `subscribed: true`.
|
|
18
|
+
*
|
|
19
|
+
* With this registry a channel is opened on the first acquire and only really
|
|
20
|
+
* left when the last holder releases it.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately plain JS: no React, no DOM, no laravel-echo import. It is
|
|
23
|
+
* framework-free so `node --test` can import the source directly, and so it
|
|
24
|
+
* works with whatever the host handed us as an "Echo instance".
|
|
25
|
+
*
|
|
26
|
+
* Nothing here throws. A registry that breaks the subscription it is meant to
|
|
27
|
+
* protect would be worse than the bug it fixes.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `WeakMap<instance, Map<name, {subscription, refs}>>`.
|
|
32
|
+
*
|
|
33
|
+
* Keyed per Echo instance rather than by name alone: an app may hold more than
|
|
34
|
+
* one broadcaster (and every test file certainly does), and two instances'
|
|
35
|
+
* `call-queue-monitor.production` are two different channels. Weak so a
|
|
36
|
+
* discarded Echo instance takes its entries with it.
|
|
37
|
+
*/
|
|
38
|
+
let registries = new WeakMap();
|
|
39
|
+
|
|
40
|
+
/** The per-instance map, created on demand. */
|
|
41
|
+
const registryFor = (instance) => {
|
|
42
|
+
let registry = registries.get(instance);
|
|
43
|
+
|
|
44
|
+
if (!registry) {
|
|
45
|
+
registry = new Map();
|
|
46
|
+
registries.set(instance, registry);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return registry;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Echo's own channel cache, when the connector exposes one.
|
|
54
|
+
*
|
|
55
|
+
* `PusherConnector`/`SocketIoConnector` both keep `this.channels`, keyed by the
|
|
56
|
+
* PREFIXED name (`private-<name>` for a private channel), and `leaveChannel()`
|
|
57
|
+
* deletes from it. Reading it is how we notice that somebody outside this
|
|
58
|
+
* registry — an older build, another library, a host's own cleanup — has torn
|
|
59
|
+
* our channel down behind our back. A connector without it (a null broadcaster,
|
|
60
|
+
* a stub in a test) simply leaves the registry as the only source of truth.
|
|
61
|
+
*/
|
|
62
|
+
const echoChannelCache = (instance) => {
|
|
63
|
+
try {
|
|
64
|
+
const channels = instance?.connector?.channels;
|
|
65
|
+
|
|
66
|
+
return channels && typeof channels === 'object' ? channels : null;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Is Echo still holding `private-<name>`? `null` = it cannot tell us. */
|
|
73
|
+
const echoHolds = (instance, name) => {
|
|
74
|
+
const cache = echoChannelCache(instance);
|
|
75
|
+
|
|
76
|
+
if (!cache) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return Object.prototype.hasOwnProperty.call(cache, `private-${name}`);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Subscribe to a private channel, or join the subscription already open.
|
|
85
|
+
*
|
|
86
|
+
* `instance.private(name)` is called on the first acquire for this
|
|
87
|
+
* (instance, name) pair only; later acquires bump the count and hand back the
|
|
88
|
+
* same subscription object, so every holder listens on one channel.
|
|
89
|
+
*
|
|
90
|
+
* One exception: if Echo's own cache no longer holds the channel — somebody
|
|
91
|
+
* called `leave()` outside this registry — the cached subscription is dead and
|
|
92
|
+
* handing it back would give the caller an object that will never fire again.
|
|
93
|
+
* In that case we resubscribe and store the new one.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} instance Echo instance.
|
|
96
|
+
* @param {string} name Channel name, unprefixed (Echo adds `private-`).
|
|
97
|
+
*
|
|
98
|
+
* @returns {object|null} The subscription, or null when there is nothing to
|
|
99
|
+
* subscribe to (no instance, no name, `private` threw).
|
|
100
|
+
*/
|
|
101
|
+
export const acquirePrivateChannel = (instance, name) => {
|
|
102
|
+
if (!instance || typeof instance.private !== 'function' || !name) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const registry = registryFor(instance);
|
|
107
|
+
const entry = registry.get(name);
|
|
108
|
+
|
|
109
|
+
// A live entry whose channel Echo still has (or cannot report on) is the
|
|
110
|
+
// ordinary case: share it.
|
|
111
|
+
if (entry && echoHolds(instance, name) !== false) {
|
|
112
|
+
entry.refs += 1;
|
|
113
|
+
|
|
114
|
+
return entry.subscription;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const subscription = instance.private(name);
|
|
118
|
+
|
|
119
|
+
if (entry) {
|
|
120
|
+
// Torn down behind our back: keep the holders we know about, replace
|
|
121
|
+
// the corpse they are holding.
|
|
122
|
+
entry.subscription = subscription;
|
|
123
|
+
entry.refs += 1;
|
|
124
|
+
} else {
|
|
125
|
+
registry.set(name, { subscription, refs: 1 });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return subscription;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Let go of a private channel. `instance.leave(name)` runs only when the last
|
|
133
|
+
* holder releases it; releasing a name nobody acquired is a no-op.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} instance Echo instance.
|
|
136
|
+
* @param {string} name Channel name, unprefixed.
|
|
137
|
+
*
|
|
138
|
+
* @returns {number} Holders remaining (0 once the channel has been left).
|
|
139
|
+
*/
|
|
140
|
+
export const releasePrivateChannel = (instance, name) => {
|
|
141
|
+
if (!instance || !name) {
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const registry = registries.get(instance);
|
|
146
|
+
const entry = registry?.get(name);
|
|
147
|
+
|
|
148
|
+
if (!entry) {
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
entry.refs -= 1;
|
|
153
|
+
|
|
154
|
+
if (entry.refs > 0) {
|
|
155
|
+
return entry.refs;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
registry.delete(name);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
instance.leave?.(name);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// Already gone, or an instance that never had a socket. Either way the
|
|
164
|
+
// registry is clean, which is the part we control.
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return 0;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Is this channel actually still subscribed — and, when the caller says what
|
|
172
|
+
* it is holding, is THAT the subscription the registry currently has?
|
|
173
|
+
*
|
|
174
|
+
* True only when the registry has a live entry AND — when the connector
|
|
175
|
+
* exposes its cache — Echo still holds `private-<name>`. The second half is
|
|
176
|
+
* the whole point: an external `leave()` leaves our ref count untouched but
|
|
177
|
+
* kills the channel, and that mismatch is precisely the failure this module
|
|
178
|
+
* was written for. Callers use it to notice and resubscribe.
|
|
179
|
+
*
|
|
180
|
+
* The optional `held` argument closes a race between two self-healing
|
|
181
|
+
* holders. When holder A notices the channel gone and rebuilds it, the
|
|
182
|
+
* registry (and Echo's cache) hold a fresh subscription again — so holder B's
|
|
183
|
+
* check, a moment later, would see "present" and keep listening on the dead
|
|
184
|
+
* object A just replaced. Passing what you hold makes the answer "no" until
|
|
185
|
+
* you have re-acquired, so both holders end up on the live subscription.
|
|
186
|
+
*
|
|
187
|
+
* @param {object} instance Echo instance.
|
|
188
|
+
* @param {string} name Channel name, unprefixed.
|
|
189
|
+
* @param {object} [held] The subscription this caller is listening on.
|
|
190
|
+
*
|
|
191
|
+
* @returns {boolean}
|
|
192
|
+
*/
|
|
193
|
+
export const hasPrivateChannel = (instance, name, held = undefined) => {
|
|
194
|
+
if (!instance || !name) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const entry = registries.get(instance)?.get(name);
|
|
199
|
+
|
|
200
|
+
if (!entry) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (held !== undefined && entry.subscription !== held) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// `null` = the connector cannot tell us; the registry is then all we have.
|
|
209
|
+
return echoHolds(instance, name) !== false;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** Drop every entry. Used by tests; never call this from application code. */
|
|
213
|
+
export const _resetSharedPrivateChannels = () => {
|
|
214
|
+
registries = new WeakMap();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export default acquirePrivateChannel;
|
|
@@ -90,6 +90,10 @@ const ZoomPhoneBadgeInner = ({
|
|
|
90
90
|
// somebody is looking at is worth more than one behind a closed chip.
|
|
91
91
|
pollInterval = 60_000,
|
|
92
92
|
openPollInterval = 20_000,
|
|
93
|
+
// How often the subscription confirms it still has its channel. Left
|
|
94
|
+
// undefined so useZoomPhoneLive's own default (60s) applies; a host only
|
|
95
|
+
// passes this to slow the check down, or to switch it off with 0.
|
|
96
|
+
resubscribeCheckMs,
|
|
93
97
|
// ⌘⇧U / Ctrl+Shift+U. Not ⌘⇧P: browsers open a private window on that.
|
|
94
98
|
shortcut = 'mod+shift+u',
|
|
95
99
|
navigate,
|
|
@@ -194,6 +198,7 @@ const ZoomPhoneBadgeInner = ({
|
|
|
194
198
|
echo,
|
|
195
199
|
channel,
|
|
196
200
|
onPresence: handlePresence,
|
|
201
|
+
resubscribeCheckMs,
|
|
197
202
|
});
|
|
198
203
|
|
|
199
204
|
/*
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// `node --test`, before tests/jsxHooks.mjs (which is what resolves the
|
|
16
16
|
// library's extensionless imports) has been registered.
|
|
17
17
|
import { initialsFor, normaliseNumberForDisplay } from '../sms/smsHelpers.js';
|
|
18
|
+
import { isDeadConnectionState } from '../sms/smsLiveState.js';
|
|
18
19
|
|
|
19
20
|
export { initialsFor, normaliseNumberForDisplay };
|
|
20
21
|
|
|
@@ -296,3 +297,33 @@ export const freshnessLabel = (fetchedAt, live, now = Date.now()) => {
|
|
|
296
297
|
|
|
297
298
|
return `Updated ${Math.floor(seconds / 3600)}h ago`;
|
|
298
299
|
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Has the roster's channel been taken away, and is now the moment to rebuild it?
|
|
303
|
+
*
|
|
304
|
+
* The failure being watched for is silent: another component calling
|
|
305
|
+
* `echo.leave()` on the shared `call-queue-monitor.{env}` channel unsubscribes
|
|
306
|
+
* everybody without raising a single event, so `live` stays true and the
|
|
307
|
+
* presence events simply stop. Nothing announces it — the only way to find out
|
|
308
|
+
* is to look, which is why the hook looks on a timer.
|
|
309
|
+
*
|
|
310
|
+
* `present` is what the shared-channel registry says (`hasPrivateChannel`), and
|
|
311
|
+
* `state` is the pusher connection state. A dead socket is deliberately NOT a
|
|
312
|
+
* reason to resubscribe: nothing can be subscribed while it is down, pusher-js
|
|
313
|
+
* restores what it still holds when it returns, and the hook re-checks on every
|
|
314
|
+
* `connected` transition anyway. An unknown state (`null` — the host's Echo may
|
|
315
|
+
* expose no reachable connection object) is not treated as dead.
|
|
316
|
+
*
|
|
317
|
+
* @param {object} options
|
|
318
|
+
* @param {boolean} options.present Does the registry still hold the channel?
|
|
319
|
+
* @param {string|null} [options.state] Pusher connection state, when known.
|
|
320
|
+
*
|
|
321
|
+
* @returns {boolean}
|
|
322
|
+
*/
|
|
323
|
+
export const shouldResubscribe = ({ present, state = null } = {}) => {
|
|
324
|
+
if (present) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return !isDeadConnectionState(state);
|
|
329
|
+
};
|
|
@@ -6,9 +6,30 @@ import {
|
|
|
6
6
|
subscriptionMonitor,
|
|
7
7
|
} from '../sms/smsLiveState';
|
|
8
8
|
|
|
9
|
+
// The call queue pop listens on this SAME channel. Echo's `leave()` destroys a
|
|
10
|
+
// channel for everyone, so neither of us may call it directly — see
|
|
11
|
+
// sharedPrivateChannel.js.
|
|
12
|
+
import {
|
|
13
|
+
acquirePrivateChannel,
|
|
14
|
+
hasPrivateChannel,
|
|
15
|
+
releasePrivateChannel,
|
|
16
|
+
} from '../echo/sharedPrivateChannel';
|
|
17
|
+
|
|
18
|
+
import { shouldResubscribe } from './phonePresenceHelpers';
|
|
19
|
+
|
|
9
20
|
/** The event the backend broadcasts when one extension's state changes. */
|
|
10
21
|
export const EVENT_PRESENCE = '.phone.presence';
|
|
11
22
|
|
|
23
|
+
/**
|
|
24
|
+
* How often the hook confirms its channel is still subscribed.
|
|
25
|
+
*
|
|
26
|
+
* A socket that drops announces itself; a channel destroyed by somebody else's
|
|
27
|
+
* `echo.leave()` announces nothing at all, so the only way to notice is to
|
|
28
|
+
* look. Once a minute costs a property read and bounds the blind window at a
|
|
29
|
+
* minute — the same figure, for the same reason, as the call pop's own check.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_RESUBSCRIBE_CHECK_MS = 60000;
|
|
32
|
+
|
|
12
33
|
/**
|
|
13
34
|
* Live phone presence: Pusher when it is wired up, polling when it is not.
|
|
14
35
|
*
|
|
@@ -28,11 +49,20 @@ export const EVENT_PRESENCE = '.phone.presence';
|
|
|
28
49
|
* user without the permission never opens a Pusher connection at all. The
|
|
29
50
|
* account has a hard concurrent-connection quota.
|
|
30
51
|
*
|
|
52
|
+
* The channel is shared, and a shared channel can be taken away without a
|
|
53
|
+
* word: `echo.leave()` anywhere else destroys it for every holder. So this
|
|
54
|
+
* hook does what the call pop does — rechecks the channel on every socket
|
|
55
|
+
* reconnect and every `resubscribeCheckMs` while the tab is visible, and
|
|
56
|
+
* rebuilds its subscription when it finds the channel gone. Without that the
|
|
57
|
+
* roster stays on a green `Live` stamp and never receives another event.
|
|
58
|
+
*
|
|
31
59
|
* @param {object} options
|
|
32
60
|
* @param {object|Function|null} options.echo Echo instance, or `() => echo`.
|
|
33
61
|
* @param {string|null} options.channel Private channel name.
|
|
34
62
|
* @param {boolean} [options.enabled] Off entirely when false.
|
|
35
63
|
* @param {Function} [options.onPresence] `({cleared, keys, call}) => void`.
|
|
64
|
+
* @param {number} [options.resubscribeCheckMs] How often to confirm the
|
|
65
|
+
* channel is still there (ms, visible tabs only). 0 turns the check off.
|
|
36
66
|
*
|
|
37
67
|
* @returns {{live: boolean}}
|
|
38
68
|
*/
|
|
@@ -41,6 +71,7 @@ const useZoomPhoneLive = ({
|
|
|
41
71
|
channel = null,
|
|
42
72
|
enabled = true,
|
|
43
73
|
onPresence,
|
|
74
|
+
resubscribeCheckMs = DEFAULT_RESUBSCRIBE_CHECK_MS,
|
|
44
75
|
} = {}) => {
|
|
45
76
|
const [live, setLive] = useState(false);
|
|
46
77
|
|
|
@@ -78,15 +109,24 @@ const useZoomPhoneLive = ({
|
|
|
78
109
|
let subscription = null;
|
|
79
110
|
let unmonitor = () => {};
|
|
80
111
|
let unbindState = () => {};
|
|
112
|
+
let resubscribeTimer = null;
|
|
81
113
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Bind the presence listener and the confirmation monitor to a channel.
|
|
116
|
+
*
|
|
117
|
+
* Broken out of the effect body because a resubscribe has to do it all
|
|
118
|
+
* again against a brand new subscription object — the old one is a
|
|
119
|
+
* corpse once Echo has dropped the channel.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} target The subscription to bind to.
|
|
122
|
+
* @returns {Function} Stops the monitor again.
|
|
123
|
+
*/
|
|
124
|
+
const attachListener = (target) => {
|
|
125
|
+
target.listen(EVENT_PRESENCE, (event) => {
|
|
86
126
|
presenceRef.current?.(event || {});
|
|
87
127
|
});
|
|
88
128
|
|
|
89
|
-
|
|
129
|
+
return subscriptionMonitor(target, {
|
|
90
130
|
onSuccess: () => {
|
|
91
131
|
confirmed = true;
|
|
92
132
|
sync();
|
|
@@ -96,6 +136,13 @@ const useZoomPhoneLive = ({
|
|
|
96
136
|
sync();
|
|
97
137
|
},
|
|
98
138
|
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Ref-counted: the call pop is very likely already on this
|
|
143
|
+
// channel, and the two must share one subscription.
|
|
144
|
+
subscription = acquirePrivateChannel(instance, channel);
|
|
145
|
+
unmonitor = attachListener(subscription);
|
|
99
146
|
} catch (error) {
|
|
100
147
|
// A channel that will not subscribe leaves the poller in charge,
|
|
101
148
|
// which is the whole point of the poller.
|
|
@@ -104,6 +151,67 @@ const useZoomPhoneLive = ({
|
|
|
104
151
|
return undefined;
|
|
105
152
|
}
|
|
106
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Confirm the channel is still there, and rebuild it when it is not.
|
|
156
|
+
*
|
|
157
|
+
* Never throws: this runs from a socket callback and from an interval,
|
|
158
|
+
* and a roster that cannot resubscribe must fall back to its poll, not
|
|
159
|
+
* take the header down with it.
|
|
160
|
+
*
|
|
161
|
+
* @returns {boolean} True when a resubscribe was performed.
|
|
162
|
+
*/
|
|
163
|
+
const ensureSubscribed = () => {
|
|
164
|
+
let present = false;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
present = hasPrivateChannel(instance, channel, subscription);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
present = false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!shouldResubscribe({ present, state: connectionState })) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
unmonitor();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// Already gone.
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
unmonitor = () => {};
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
subscription?.stopListening(EVENT_PRESENCE);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// A dead channel has nothing left to unbind.
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Release before acquiring so the ref count nets out unchanged:
|
|
191
|
+
// this hook held one reference before and holds one after.
|
|
192
|
+
releasePrivateChannel(instance, channel);
|
|
193
|
+
|
|
194
|
+
// Nothing has arrived on the new subscription yet, so `live` goes
|
|
195
|
+
// back to false until the monitor confirms it. A green stamp over
|
|
196
|
+
// an unconfirmed channel is the exact lie this hook exists to
|
|
197
|
+
// avoid telling.
|
|
198
|
+
confirmed = false;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
subscription = acquirePrivateChannel(instance, channel);
|
|
202
|
+
unmonitor = attachListener(subscription);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
subscription = null;
|
|
205
|
+
sync();
|
|
206
|
+
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
sync();
|
|
211
|
+
|
|
212
|
+
return true;
|
|
213
|
+
};
|
|
214
|
+
|
|
107
215
|
// The socket underneath. When it goes, the channel goes with it whatever
|
|
108
216
|
// the confirmation said; pusher-js resubscribes on its own once it is
|
|
109
217
|
// back, and the subscribed callback re-confirms.
|
|
@@ -118,6 +226,11 @@ const useZoomPhoneLive = ({
|
|
|
118
226
|
|
|
119
227
|
if (isDeadConnectionState(current)) confirmed = false;
|
|
120
228
|
|
|
229
|
+
// A socket can come back without our channel: pusher-js
|
|
230
|
+
// resubscribes what it still holds, and a channel someone
|
|
231
|
+
// else `leave()`d is no longer among them.
|
|
232
|
+
if (current === 'connected') ensureSubscribed();
|
|
233
|
+
|
|
121
234
|
sync();
|
|
122
235
|
};
|
|
123
236
|
|
|
@@ -137,9 +250,32 @@ const useZoomPhoneLive = ({
|
|
|
137
250
|
// decides, which is the same answer we gave before.
|
|
138
251
|
}
|
|
139
252
|
|
|
253
|
+
// The backstop. `state_change` covers a socket that visibly went away;
|
|
254
|
+
// this covers the case with no signal at all — the channel removed
|
|
255
|
+
// underneath a perfectly healthy socket. Visible tabs only: a roster
|
|
256
|
+
// nobody is looking at has nothing to catch up for, and the badge's own
|
|
257
|
+
// poll refills it either way.
|
|
258
|
+
if (resubscribeCheckMs > 0) {
|
|
259
|
+
resubscribeTimer = setInterval(() => {
|
|
260
|
+
if (
|
|
261
|
+
typeof document !== 'undefined' &&
|
|
262
|
+
document.visibilityState !== 'visible'
|
|
263
|
+
) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
ensureSubscribed();
|
|
268
|
+
}, resubscribeCheckMs);
|
|
269
|
+
}
|
|
270
|
+
|
|
140
271
|
sync();
|
|
141
272
|
|
|
142
273
|
return () => {
|
|
274
|
+
if (resubscribeTimer !== null) {
|
|
275
|
+
clearInterval(resubscribeTimer);
|
|
276
|
+
resubscribeTimer = null;
|
|
277
|
+
}
|
|
278
|
+
|
|
143
279
|
unbindState();
|
|
144
280
|
|
|
145
281
|
try {
|
|
@@ -150,14 +286,18 @@ const useZoomPhoneLive = ({
|
|
|
150
286
|
|
|
151
287
|
try {
|
|
152
288
|
subscription?.stopListening(EVENT_PRESENCE);
|
|
153
|
-
|
|
289
|
+
// Our own listener goes, the channel only if nobody else (the
|
|
290
|
+
// call pop) is still holding it. The header hides itself on
|
|
291
|
+
// record pages, so this cleanup runs while the pop is very
|
|
292
|
+
// much still watching.
|
|
293
|
+
releasePrivateChannel(instance, channel);
|
|
154
294
|
} catch (error) {
|
|
155
295
|
// Nothing to clean up.
|
|
156
296
|
}
|
|
157
297
|
|
|
158
298
|
setLive(false);
|
|
159
299
|
};
|
|
160
|
-
}, [enabled, channel]);
|
|
300
|
+
}, [enabled, channel, resubscribeCheckMs]);
|
|
161
301
|
|
|
162
302
|
return { live };
|
|
163
303
|
};
|
|
@@ -210,6 +210,19 @@
|
|
|
210
210
|
white-space: nowrap;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/* Who an email reached, under the head and above the message.
|
|
214
|
+
Deliberately quiet — it is a qualifier on the entry, not part of it — and
|
|
215
|
+
deliberately NOT truncated to "+3 more": the recipient worth noticing is the
|
|
216
|
+
one nobody expected, which is exactly the one a fold would hide. So it wraps
|
|
217
|
+
and takes as many lines as it needs. */
|
|
218
|
+
.recipients {
|
|
219
|
+
margin: -0.15rem 0 0.45rem;
|
|
220
|
+
color: var(--muted-color, #6b7280);
|
|
221
|
+
font-size: 0.74rem;
|
|
222
|
+
line-height: 1.4;
|
|
223
|
+
overflow-wrap: anywhere;
|
|
224
|
+
}
|
|
225
|
+
|
|
213
226
|
.body {
|
|
214
227
|
margin: 0;
|
|
215
228
|
color: var(--paragraph-color, #2b2b2b);
|
|
@@ -518,10 +531,18 @@
|
|
|
518
531
|
cursor: pointer;
|
|
519
532
|
}
|
|
520
533
|
|
|
521
|
-
/* The consequence in words, always present
|
|
534
|
+
/* The consequence in words, always present — and since it now NAMES the people
|
|
535
|
+
a reply reaches, it has to survive a Cc of five. `min-width: 0` is the
|
|
536
|
+
load-bearing half: a flex item's default `min-width: auto` refuses to shrink
|
|
537
|
+
below its longest unbreakable run, which would push the sentence out of the
|
|
538
|
+
bar rather than wrapping it. */
|
|
522
539
|
.consequence {
|
|
540
|
+
flex: 1 1 auto;
|
|
541
|
+
min-width: 0;
|
|
523
542
|
color: var(--muted-color, #6b7280);
|
|
524
543
|
font-size: 0.76rem;
|
|
544
|
+
line-height: 1.4;
|
|
545
|
+
overflow-wrap: anywhere;
|
|
525
546
|
}
|
|
526
547
|
|
|
527
548
|
.composerInput {
|