@sphoro/bugcatch-web-sdk 0.17.0 → 0.19.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 +104 -6
- package/dist/bugcatch-call.es.js +587 -0
- package/dist/bugcatch-call.es.js.map +1 -0
- package/dist/bugcatch-call.umd.js +2 -0
- package/dist/bugcatch-call.umd.js.map +1 -0
- package/dist/bugcatch-core.es.js +3292 -0
- package/dist/bugcatch-core.es.js.map +1 -0
- package/dist/bugcatch-panel.es.js +2531 -0
- package/dist/bugcatch-panel.es.js.map +1 -0
- package/dist/bugcatch-panel.umd.js +1311 -0
- package/dist/bugcatch-panel.umd.js.map +1 -0
- package/dist/bugcatch.es.js +2 -5625
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.standalone.umd.js +1619 -0
- package/dist/bugcatch.standalone.umd.js.map +1 -0
- package/dist/bugcatch.umd.js +195 -1318
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/accent.js +38 -0
- package/src/announcements.js +147 -0
- package/src/busy.js +18 -0
- package/src/call.js +7 -0
- package/src/capture.js +9 -0
- package/src/chunks.js +56 -0
- package/src/gesture.js +33 -0
- package/src/icons.js +18 -0
- package/src/identity.js +13 -8
- package/src/incoming-call.js +25 -45
- package/src/index.d.ts +93 -2
- package/src/index.js +164 -42
- package/src/lazy.js +23 -0
- package/src/notice.js +68 -0
- package/src/panel/call.js +242 -0
- package/src/panel/compose.js +416 -0
- package/src/panel/composer.js +235 -0
- package/src/panel/icons.js +36 -0
- package/src/panel/index.js +265 -0
- package/src/panel/known.js +130 -0
- package/src/panel/messages.js +176 -0
- package/src/panel/notices.js +34 -0
- package/src/panel/picks.js +65 -0
- package/src/panel/refs.js +321 -0
- package/src/panel/render.js +132 -0
- package/src/panel/signin.js +228 -0
- package/src/panel/styles.js +1016 -0
- package/src/panel/thread.js +494 -0
- package/src/session.js +8 -3
- package/src/umd/call.js +6 -0
- package/src/umd/lazy.js +98 -0
- package/src/umd/panel.js +6 -0
- package/src/updates.js +97 -34
- package/src/widget.js +385 -3376
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Which record is this about.
|
|
2
|
+
//
|
|
3
|
+
// What this project asks for, and what the reporter has answered.
|
|
4
|
+
//
|
|
5
|
+
// The question is asked at Send and not before, which is the whole design of
|
|
6
|
+
// it. A form that opens with "loan number" above "what went wrong" tells
|
|
7
|
+
// somebody they have paperwork to do before they may complain, and most of
|
|
8
|
+
// them will not — while the same question, asked once, after they have said
|
|
9
|
+
// their piece, and only when what they said did not already contain the
|
|
10
|
+
// answer, is a reasonable thing to be asked. Most reporters never see it: the
|
|
11
|
+
// loan number was in the sentence.
|
|
12
|
+
|
|
13
|
+
import * as ICONS from './icons.js';
|
|
14
|
+
import { readReferences, validateReference } from '../references.js';
|
|
15
|
+
import { escapeHtml } from './render.js';
|
|
16
|
+
|
|
17
|
+
export function mountReferences(ctx) {
|
|
18
|
+
const {
|
|
19
|
+
client, say, titleInput, descInput, sendBtn, refsBox,
|
|
20
|
+
} = ctx;
|
|
21
|
+
|
|
22
|
+
let refConfig = { policy: 'off', fields: [] };
|
|
23
|
+
let refOpen = false;
|
|
24
|
+
let refChoice = null; // which field they are answering; null = not picked yet
|
|
25
|
+
let refValue = '';
|
|
26
|
+
let refError = '';
|
|
27
|
+
// What we read in their text but could not place for sure, offered as taps:
|
|
28
|
+
// [{ value, kinds: [{ key, label }] }]. `refMode` says why the card is open —
|
|
29
|
+
// 'confirm' when the text had two answers, 'suggest' when it had an unlabelled
|
|
30
|
+
// one, 'ask' when it had none.
|
|
31
|
+
let refCandidates = [];
|
|
32
|
+
let refMode = 'ask';
|
|
33
|
+
// Set once they have answered or explicitly skipped, so Send stops asking.
|
|
34
|
+
let refSettled = false;
|
|
35
|
+
// Set once we have said "that does not look right" about the current value.
|
|
36
|
+
// Its own flag rather than reusing refSettled, which was the first attempt
|
|
37
|
+
// and was a loop: the warning returns early, the next press re-validates the
|
|
38
|
+
// same unchanged value, warns again, and the report can never be sent. One
|
|
39
|
+
// warning per value, and then it is their call.
|
|
40
|
+
let refWarned = false;
|
|
41
|
+
|
|
42
|
+
client.referenceFields()
|
|
43
|
+
.then((cfg) => { refConfig = cfg; showFound(); })
|
|
44
|
+
.catch(() => { /* off is the safe answer to every failure */ });
|
|
45
|
+
|
|
46
|
+
/** What the text says: found (sure), conflicts (ask which), loose (offer). */
|
|
47
|
+
function readText() {
|
|
48
|
+
if (refConfig.policy === 'off' || !refConfig.fields.length) return { found: [], conflicts: [], loose: [] };
|
|
49
|
+
return readReferences(refConfig.fields, [titleInput.value, descInput.value]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Sure enough to send without a question: something found, nothing contested. */
|
|
53
|
+
function textAnswers(read = readText()) {
|
|
54
|
+
return read.found.length > 0 && read.conflicts.length === 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The answer to send, or an empty list. */
|
|
58
|
+
function answeredReference() {
|
|
59
|
+
if (!refChoice || !refValue.trim()) return [];
|
|
60
|
+
return [{ key: refChoice, value: refValue.trim().slice(0, 120) }];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A quiet line under the description while they type, so somebody who put
|
|
64
|
+
// their loan number in the sentence can see it was read and does not go
|
|
65
|
+
// looking for a box to type it into again.
|
|
66
|
+
const foundNote = document.createElement('p');
|
|
67
|
+
foundNote.className = 'ref-found';
|
|
68
|
+
foundNote.setAttribute('aria-live', 'polite');
|
|
69
|
+
foundNote.hidden = true;
|
|
70
|
+
descInput.closest('.field').after(foundNote);
|
|
71
|
+
let foundTimer;
|
|
72
|
+
function showFound() {
|
|
73
|
+
const read = readText();
|
|
74
|
+
if (textAnswers(read)) {
|
|
75
|
+
const list = read.found.slice(0, 2).map((r) => `${escapeHtml(r.label)} <b>${escapeHtml(shortId(r.value))}</b>`).join(', ');
|
|
76
|
+
foundNote.innerHTML = `${ICONS.check}<span>Found your ${list} — no need to add it again.</span>`;
|
|
77
|
+
foundNote.hidden = false;
|
|
78
|
+
// They have answered in the text; a card still asking would be not listening.
|
|
79
|
+
if (refOpen && !answeredReference().length) { refOpen = false; drawRefs(); }
|
|
80
|
+
} else {
|
|
81
|
+
foundNote.hidden = true;
|
|
82
|
+
foundNote.innerHTML = '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const queueFound = () => { clearTimeout(foundTimer); foundTimer = setTimeout(showFound, 250); };
|
|
86
|
+
titleInput.addEventListener('input', queueFound);
|
|
87
|
+
descInput.addEventListener('input', queueFound);
|
|
88
|
+
|
|
89
|
+
/** A UUID is long; the chip shows its ends, the title shows all of it. */
|
|
90
|
+
function shortId(value) {
|
|
91
|
+
return value.length > 20 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function candidateFor(value) {
|
|
95
|
+
const v = value.trim().toLowerCase();
|
|
96
|
+
return refCandidates.find((c) => c.value.toLowerCase() === v) || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Pick a candidate: its value, and its kind when only one kind fits. */
|
|
100
|
+
function takeCandidate(c) {
|
|
101
|
+
refValue = c.value;
|
|
102
|
+
if (c.kinds.length === 1) refChoice = c.kinds[0].key;
|
|
103
|
+
else if (!c.kinds.some((k) => k.key === refChoice)) refChoice = null;
|
|
104
|
+
refError = '';
|
|
105
|
+
refWarned = false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function drawRefs() {
|
|
109
|
+
if (!refOpen) { refsBox.hidden = true; refsBox.innerHTML = ''; return; }
|
|
110
|
+
const field = refConfig.fields.find((f) => f.key === refChoice) || null;
|
|
111
|
+
const optional = refConfig.policy !== 'required';
|
|
112
|
+
const current = candidateFor(refValue);
|
|
113
|
+
// Kinds worth offering: the ones the picked value could be, when we know;
|
|
114
|
+
// every kind otherwise. Never zero — a guess of ours must not lock out the
|
|
115
|
+
// right answer.
|
|
116
|
+
const kinds = current && current.kinds.length > 1
|
|
117
|
+
? refConfig.fields.filter((f) => current.kinds.some((k) => k.key === f.key))
|
|
118
|
+
: refConfig.fields;
|
|
119
|
+
// Two loan numbers: the kind is not in question, only which one — so the
|
|
120
|
+
// kind chips would be a second question nobody needs to answer.
|
|
121
|
+
const sharedKind = refCandidates.length > 1
|
|
122
|
+
&& refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === refCandidates[0].kinds[0].key);
|
|
123
|
+
|
|
124
|
+
const head = {
|
|
125
|
+
confirm: refCandidates.length > 1
|
|
126
|
+
? ['Which one is this about?', 'We found more than one ID in your message. Pick the one this problem is about.']
|
|
127
|
+
: ['Is this the right ID?', 'This could be more than one kind of ID. Tell us which it is.'],
|
|
128
|
+
suggest: ['Is this your ID?', 'We spotted this in your message. Tell us what it is so we can find your record.'],
|
|
129
|
+
ask: optional
|
|
130
|
+
? ['One more thing', 'So we can find your record — this helps us answer faster.']
|
|
131
|
+
: ['We need one detail', 'We need one of these to look this up.'],
|
|
132
|
+
}[refMode];
|
|
133
|
+
|
|
134
|
+
refsBox.hidden = false;
|
|
135
|
+
// A handful of kinds read best as chips to tap; a long list goes back to
|
|
136
|
+
// a select so the card does not turn into a wall of pills.
|
|
137
|
+
const many = kinds.length > 6;
|
|
138
|
+
refsBox.innerHTML = `
|
|
139
|
+
<div class="ask-head">
|
|
140
|
+
<span class="avatar orb" aria-hidden="true"></span>
|
|
141
|
+
<div>
|
|
142
|
+
<strong>${escapeHtml(head[0])}</strong>
|
|
143
|
+
<p class="why">${escapeHtml(head[1])}</p>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
${refCandidates.length > 1 ? `
|
|
147
|
+
<div class="chips cands" role="group" aria-label="IDs found in your message">
|
|
148
|
+
${refCandidates.map((c, i) => `
|
|
149
|
+
<button type="button" data-ref-cand="${i}" title="${escapeHtml(c.value)}"
|
|
150
|
+
aria-pressed="${!!current && current === c}">${escapeHtml(shortId(c.value))}</button>
|
|
151
|
+
`).join('')}
|
|
152
|
+
</div>` : ''}
|
|
153
|
+
${kinds.length > 1 && !many && !sharedKind ? `
|
|
154
|
+
<div class="chips" role="group" aria-label="Which detail you are giving">
|
|
155
|
+
${kinds.map((f) => `
|
|
156
|
+
<button type="button" data-ref-kind="${escapeHtml(f.key)}" aria-pressed="${!!field && f.key === field.key}">${escapeHtml(f.label)}</button>
|
|
157
|
+
`).join('')}
|
|
158
|
+
</div>` : ''}
|
|
159
|
+
<div class="row">
|
|
160
|
+
${many ? `
|
|
161
|
+
<select class="ref-kind" aria-label="Which detail you are giving">
|
|
162
|
+
${field ? '' : '<option value="" selected disabled>What is it?</option>'}
|
|
163
|
+
${kinds.map((f) => `
|
|
164
|
+
<option value="${escapeHtml(f.key)}" ${field && f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
|
|
165
|
+
`).join('')}
|
|
166
|
+
</select>` : ''}
|
|
167
|
+
<input class="ref-value${refError ? ' invalid' : ''}" type="${field?.inputMode === 'email' ? 'email' : 'text'}"
|
|
168
|
+
inputmode="${escapeHtml(field?.inputMode === 'numeric' || field?.inputMode === 'tel' ? 'numeric' : 'text')}"
|
|
169
|
+
value="${escapeHtml(refValue)}"
|
|
170
|
+
placeholder="${escapeHtml(field ? (field.placeholder || field.label) : 'Your ID')}"
|
|
171
|
+
aria-label="${escapeHtml(field ? field.label : 'Your ID')}" ${refError ? 'aria-invalid="true"' : ''} />
|
|
172
|
+
</div>
|
|
173
|
+
${field?.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
|
|
174
|
+
${refError ? `<p class="field-err" role="alert">${ICONS.alert}<span>${escapeHtml(refError)}</span></p>` : ''}
|
|
175
|
+
${optional ? `<button type="button" class="link" data-act="ref-skip">${
|
|
176
|
+
refMode === 'ask' ? 'I don\'t have this — send without it' : 'Not sure — send as it is'}</button>` : ''}`;
|
|
177
|
+
|
|
178
|
+
refsBox.querySelectorAll('[data-ref-cand]').forEach((chip) => {
|
|
179
|
+
chip.addEventListener('click', () => {
|
|
180
|
+
takeCandidate(refCandidates[Number(chip.dataset.refCand)]);
|
|
181
|
+
drawRefs();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
refsBox.querySelectorAll('[data-ref-kind]').forEach((chip) => {
|
|
185
|
+
chip.addEventListener('click', () => {
|
|
186
|
+
refChoice = chip.dataset.refKind;
|
|
187
|
+
refError = '';
|
|
188
|
+
refWarned = false;
|
|
189
|
+
drawRefs();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
const value = refsBox.querySelector('.ref-value');
|
|
193
|
+
value.addEventListener('input', () => {
|
|
194
|
+
refValue = value.value;
|
|
195
|
+
refError = '';
|
|
196
|
+
// A changed value has not been warned about yet.
|
|
197
|
+
refWarned = false;
|
|
198
|
+
});
|
|
199
|
+
// Enter in the box is the same gesture as pressing Send, which is what
|
|
200
|
+
// somebody who has just been asked one question expects it to be.
|
|
201
|
+
value.addEventListener('keydown', (e) => {
|
|
202
|
+
if (e.key === 'Enter') { e.preventDefault(); sendBtn.click(); }
|
|
203
|
+
});
|
|
204
|
+
refsBox.querySelector('.ref-kind')?.addEventListener('change', (e) => {
|
|
205
|
+
refChoice = e.target.value;
|
|
206
|
+
refError = '';
|
|
207
|
+
drawRefs();
|
|
208
|
+
refsBox.querySelector('.ref-value')?.focus();
|
|
209
|
+
});
|
|
210
|
+
refsBox.querySelector('[data-act="ref-skip"]')?.addEventListener('click', () => {
|
|
211
|
+
// Skipping is answering. Without this the panel asks again on the next
|
|
212
|
+
// press and the reporter is in a loop with a button that does nothing.
|
|
213
|
+
// The text still goes; the server reads it either way.
|
|
214
|
+
refSettled = true;
|
|
215
|
+
refOpen = false;
|
|
216
|
+
refChoice = null;
|
|
217
|
+
refValue = '';
|
|
218
|
+
refError = '';
|
|
219
|
+
refWarned = false;
|
|
220
|
+
drawRefs();
|
|
221
|
+
say('');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Focus where the next decision is: the kind, when that is all that is
|
|
225
|
+
// missing; the box otherwise.
|
|
226
|
+
const next = !field && refValue ? refsBox.querySelector('[data-ref-kind], .ref-kind') : null;
|
|
227
|
+
(next || value).focus();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Open the question, pre-filled with the best of what the text offered.
|
|
232
|
+
*
|
|
233
|
+
* Conflicts first — the text said two things and only the reporter knows
|
|
234
|
+
* which — then loose IDs, which are probably the answer and only need a
|
|
235
|
+
* name. Nothing, and it is the plain question it always was.
|
|
236
|
+
*/
|
|
237
|
+
function openRefs(read) {
|
|
238
|
+
refCandidates = read.conflicts.length ? read.conflicts : read.loose.slice(0, 4);
|
|
239
|
+
refMode = read.conflicts.length ? 'confirm' : read.loose.length ? 'suggest' : 'ask';
|
|
240
|
+
refOpen = true;
|
|
241
|
+
if (refCandidates.length) {
|
|
242
|
+
// One contested value: pre-fill it so they only choose what it is. Two
|
|
243
|
+
// or more: pick nothing, because picking is the question.
|
|
244
|
+
if (refCandidates.length === 1) takeCandidate(refCandidates[0]);
|
|
245
|
+
else {
|
|
246
|
+
refValue = '';
|
|
247
|
+
const [first] = refCandidates;
|
|
248
|
+
const same = refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === first.kinds[0].key);
|
|
249
|
+
refChoice = same ? first.kinds[0].key : null;
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
refChoice = refChoice || refConfig.fields[0].key;
|
|
253
|
+
}
|
|
254
|
+
drawRefs();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Whether Send should stop and ask.
|
|
259
|
+
*
|
|
260
|
+
* Never when the text answered it outright. Once when the text said two
|
|
261
|
+
* things or something unlabelled — on every policy, because sending the
|
|
262
|
+
* wrong record's id with confidence is worse than one extra tap. And the
|
|
263
|
+
* plain question on `optional`/`required` projects as before.
|
|
264
|
+
*/
|
|
265
|
+
function needsReference() {
|
|
266
|
+
if (refConfig.policy === 'off' || !refConfig.fields.length) return false;
|
|
267
|
+
if (answeredReference().length) return false;
|
|
268
|
+
const read = readText();
|
|
269
|
+
if (textAnswers(read)) return false;
|
|
270
|
+
if (refSettled && refConfig.policy !== 'required') return false;
|
|
271
|
+
return read;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Whether Send should stop here: true when it asked, or warned, instead.
|
|
276
|
+
*
|
|
277
|
+
* Ask, if this project asks and nothing they wrote answered it. The first
|
|
278
|
+
* press opens the question rather than sending; the second sends. That
|
|
279
|
+
* costs one extra press on projects that want an identifier, and it is the
|
|
280
|
+
* only shape that does not put paperwork in front of a person who came here
|
|
281
|
+
* to say something is broken.
|
|
282
|
+
*/
|
|
283
|
+
function holdForReference() {
|
|
284
|
+
// First, though: a value in the box but no kind picked. Asked rather than
|
|
285
|
+
// guessed — a wrong kind joins on the wrong table.
|
|
286
|
+
if (refOpen && refValue.trim() && !refChoice) {
|
|
287
|
+
refError = 'Pick what this ID is.';
|
|
288
|
+
drawRefs();
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const read = needsReference();
|
|
293
|
+
if (read) {
|
|
294
|
+
if (!refOpen) openRefs(read); else drawRefs();
|
|
295
|
+
say('');
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// They chose a field and typed something that is not that shape. Advisory
|
|
300
|
+
// on the server — a value that fails is still what they believe identifies
|
|
301
|
+
// them, and it is kept — but worth one chance to fix a typo here, where the
|
|
302
|
+
// number is still on screen.
|
|
303
|
+
if (refChoice && refValue.trim()) {
|
|
304
|
+
const field = refConfig.fields.find((f) => f.key === refChoice);
|
|
305
|
+
if (!refWarned && field && !validateReference(field, refValue)) {
|
|
306
|
+
refError = `That does not look like a ${field.label.toLowerCase()}. Press Send again to use it anyway.`;
|
|
307
|
+
// Once, and then it is their call. Our idea of the shape of somebody's
|
|
308
|
+
// account number is not better than theirs, and refusing outright is
|
|
309
|
+
// how a genuine reference gets lost — so the second press sends it and
|
|
310
|
+
// the API keeps it, marked as not matching the expected format.
|
|
311
|
+
refWarned = true;
|
|
312
|
+
drawRefs();
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
ctx.holdForReference = holdForReference;
|
|
320
|
+
ctx.answeredReference = answeredReference;
|
|
321
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Markup the panel's screens share: the header, empty and loading states, and
|
|
2
|
+
// the small formatting helpers every screen reaches for — dates, statuses and
|
|
3
|
+
// escaping. Pure functions of their arguments; nothing here touches the DOM
|
|
4
|
+
// except the two scroll helpers, which are handed the element they move.
|
|
5
|
+
|
|
6
|
+
import * as ICONS from './icons.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The panel's header, on every screen. The assistant's orb carries the
|
|
10
|
+
* identity; `back` adds the way out of a report to the list of them.
|
|
11
|
+
*
|
|
12
|
+
* `theme` is the name of the theme in force ("Light", "Dark", "System
|
|
13
|
+
* default"); given one, the header carries the button that moves to the next.
|
|
14
|
+
* The button holds all three glyphs and the stylesheet shows the one matching
|
|
15
|
+
* the host element's class, so it is right on every screen without anyone
|
|
16
|
+
* redrawing it. Its press is handled once, on the scrim (index.js).
|
|
17
|
+
*/
|
|
18
|
+
export function headHtml({ title, sub, id = '', back = false, theme = '' }) {
|
|
19
|
+
return `
|
|
20
|
+
<div class="head">
|
|
21
|
+
${back ? `<button class="icon-btn back" type="button" data-act="back" aria-label="Back to your reports">${ICONS.back}</button>` : ''}
|
|
22
|
+
<span class="orb mark" aria-hidden="true"></span>
|
|
23
|
+
<div class="titles">
|
|
24
|
+
<h2${id ? ` id="${id}"` : ''}>${escapeHtml(title)}</h2>
|
|
25
|
+
<p>${escapeHtml(sub)}</p>
|
|
26
|
+
</div>
|
|
27
|
+
${theme ? `<button class="icon-btn theme" type="button" data-act="theme" title="Theme: ${escapeHtml(theme)}"
|
|
28
|
+
aria-label="Theme: ${escapeHtml(theme)}. Change theme"><span class="m-light">${ICONS.sun}</span><span class="m-dark">${ICONS.moon}</span><span class="m-auto">${ICONS.auto}</span></button>` : ''}
|
|
29
|
+
<button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
|
|
30
|
+
</div>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function emptyHtml(text, icon) {
|
|
34
|
+
return `<div class="empty"><span class="empty-ico" aria-hidden="true">${icon}</span>${escapeHtml(text)}</div>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Placeholder rows while the list loads — the shape of what is coming. */
|
|
38
|
+
export function skeletonRows(n) {
|
|
39
|
+
return Array.from({ length: n }, () => `
|
|
40
|
+
<div class="row" aria-hidden="true">
|
|
41
|
+
<span class="row-ico"></span>
|
|
42
|
+
<span class="who"><span class="skel" style="width:70%"></span><span class="skel" style="width:40%;height:9px"></span></span>
|
|
43
|
+
</div>`).join('') + '<span class="sr-only">Loading…</span>';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function skeletonBubbles() {
|
|
47
|
+
return `
|
|
48
|
+
<div aria-hidden="true" style="display:flex;flex-direction:column;gap:10px;padding-top:6px">
|
|
49
|
+
<span class="skel" style="width:42%;height:34px;border-radius:16px;align-self:center"></span>
|
|
50
|
+
<span class="skel" style="width:66%;height:52px;border-radius:18px"></span>
|
|
51
|
+
<span class="skel" style="width:48%;height:38px;border-radius:18px;align-self:flex-end"></span>
|
|
52
|
+
</div>
|
|
53
|
+
<span class="sr-only">Loading…</span>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function initials(name) {
|
|
57
|
+
const parts = String(name).trim().split(/\s+/).filter(Boolean);
|
|
58
|
+
if (!parts.length) return '?';
|
|
59
|
+
return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function dayKey(iso) {
|
|
63
|
+
const d = new Date(iso);
|
|
64
|
+
return Number.isFinite(d.getTime()) ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** "Today" / "Yesterday" / "Mon, 14 Sep" — the label on a day separator. */
|
|
68
|
+
export function dayLabel(iso) {
|
|
69
|
+
const d = new Date(iso);
|
|
70
|
+
const today = new Date();
|
|
71
|
+
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
|
|
72
|
+
if (dayKey(d) === dayKey(today)) return 'Today';
|
|
73
|
+
if (dayKey(d) === dayKey(yesterday)) return 'Yesterday';
|
|
74
|
+
try {
|
|
75
|
+
return d.toLocaleDateString(undefined, {
|
|
76
|
+
weekday: 'short', day: 'numeric', month: 'short',
|
|
77
|
+
...(d.getFullYear() !== today.getFullYear() ? { year: 'numeric' } : {}),
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
return relativeDay(iso);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** "14:05" or "2:05 PM", in the reader's own convention. */
|
|
85
|
+
export function clockTime(iso) {
|
|
86
|
+
const d = new Date(iso);
|
|
87
|
+
if (!Number.isFinite(d.getTime())) return '';
|
|
88
|
+
try {
|
|
89
|
+
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
90
|
+
} catch {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether the reader is at the foot of the thread, within a line or two. */
|
|
96
|
+
export function atLatest(thread) {
|
|
97
|
+
return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 40;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function scrollToLatest(thread, { smooth = false } = {}) {
|
|
101
|
+
const calm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
102
|
+
if (smooth && !calm && thread.scrollTo) thread.scrollTo({ top: thread.scrollHeight, behavior: 'smooth' });
|
|
103
|
+
else thread.scrollTop = thread.scrollHeight;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Status as a word a reporter would use, not the enum the database stores. */
|
|
107
|
+
export function statusWord(status) {
|
|
108
|
+
return {
|
|
109
|
+
OPEN: 'Open',
|
|
110
|
+
IN_PROGRESS: 'In progress',
|
|
111
|
+
RESOLVED: 'Fixed',
|
|
112
|
+
CLOSED: 'Closed',
|
|
113
|
+
}[status] || String(status || '').toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** "today" / "3 days ago" — enough for a history list, no library. */
|
|
117
|
+
export function relativeDay(iso) {
|
|
118
|
+
const then = new Date(iso).getTime();
|
|
119
|
+
if (!Number.isFinite(then)) return '';
|
|
120
|
+
const days = Math.floor((Date.now() - then) / 86400000);
|
|
121
|
+
if (days <= 0) return 'today';
|
|
122
|
+
if (days === 1) return 'yesterday';
|
|
123
|
+
if (days < 30) return `${days} days ago`;
|
|
124
|
+
const months = Math.floor(days / 30);
|
|
125
|
+
return months === 1 ? 'a month ago' : `${months} months ago`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function escapeHtml(s) {
|
|
129
|
+
return String(s).replace(/[&<>"']/g, (c) => (
|
|
130
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
131
|
+
));
|
|
132
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Who is reporting.
|
|
2
|
+
//
|
|
3
|
+
// Three states, and the project picks which: 'off' is every project that has
|
|
4
|
+
// not asked for this and draws nothing at all; 'optional' offers a sign-in
|
|
5
|
+
// above the form that the reporter may ignore; 'required' replaces the form
|
|
6
|
+
// with it until they have.
|
|
7
|
+
//
|
|
8
|
+
// Asked on every open rather than remembered for the page, because the answer
|
|
9
|
+
// includes whether this browser's session is still live — and it can end
|
|
10
|
+
// somewhere else, when the same address signs in on another device. A widget
|
|
11
|
+
// holding a cached "signed in as you" would find out at the moment the report
|
|
12
|
+
// failed, which is the worst moment to discover it.
|
|
13
|
+
|
|
14
|
+
import { escapeHtml } from './render.js';
|
|
15
|
+
|
|
16
|
+
export function mountSignIn(ctx) {
|
|
17
|
+
const {
|
|
18
|
+
client, say, compose, sendBtn, historyBtn, callBtn, whoami,
|
|
19
|
+
} = ctx;
|
|
20
|
+
|
|
21
|
+
let login = { mode: client.lastLoginMode || 'off', signedIn: null };
|
|
22
|
+
// 'idle' — showing who they are; 'email' / 'code' — the two steps of getting there.
|
|
23
|
+
let step = 'idle';
|
|
24
|
+
let pendingEmail = client.config.user?.email || '';
|
|
25
|
+
let pendingCode = '';
|
|
26
|
+
let signInBusy = false;
|
|
27
|
+
let signInError = '';
|
|
28
|
+
|
|
29
|
+
const mustSignIn = () => login.mode === 'required' && !login.signedIn;
|
|
30
|
+
|
|
31
|
+
/** Show or hide everything that only makes sense once we know who is filing. */
|
|
32
|
+
function applyGate() {
|
|
33
|
+
const shut = mustSignIn();
|
|
34
|
+
compose.hidden = shut;
|
|
35
|
+
sendBtn.hidden = shut;
|
|
36
|
+
if (historyBtn) historyBtn.hidden = shut;
|
|
37
|
+
// The call offer is the same errand as the form, so it goes with it — and
|
|
38
|
+
// comes back only if the API had already said this project can take one.
|
|
39
|
+
if (shut) callBtn.hidden = true;
|
|
40
|
+
else if (ctx.callOffered()) callBtn.hidden = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function drawWhoami() {
|
|
44
|
+
if (login.mode === 'off') {
|
|
45
|
+
whoami.hidden = true;
|
|
46
|
+
whoami.innerHTML = '';
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
whoami.hidden = false;
|
|
50
|
+
whoami.classList.toggle('asking', step !== 'idle');
|
|
51
|
+
|
|
52
|
+
const problem = signInError ? `<div class="after err">${escapeHtml(signInError)}</div>` : '';
|
|
53
|
+
|
|
54
|
+
if (step === 'idle') {
|
|
55
|
+
if (login.signedIn) {
|
|
56
|
+
whoami.innerHTML = `<span class="who">Signed in as <strong>${escapeHtml(login.signedIn.email)}</strong></span>`
|
|
57
|
+
+ '<button type="button" class="link" data-act="signout">Sign out</button>';
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// An address the host application passed for its own signed-in user is
|
|
61
|
+
// not nothing — the report will carry it — so saying "anonymously" over
|
|
62
|
+
// the top of one would be untrue. It is simply not proved, which is what
|
|
63
|
+
// the offer beside it is for.
|
|
64
|
+
const claimed = client.config.user?.email;
|
|
65
|
+
whoami.innerHTML = `<span class="who">${login.mode === 'required'
|
|
66
|
+
? 'Sign in to report a bug here.'
|
|
67
|
+
: claimed
|
|
68
|
+
? `Reporting as <strong>${escapeHtml(claimed)}</strong>.`
|
|
69
|
+
: 'Reporting anonymously — sign in and we can tell you what happens next.'}</span>`
|
|
70
|
+
+ '<button type="button" class="link" data-act="signin">Sign in</button>';
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (step === 'email') {
|
|
75
|
+
whoami.innerHTML = `
|
|
76
|
+
<p class="why">Sign in with any email address — we'll send you a code.
|
|
77
|
+
It's how you hear back about this report${login.mode === 'required' ? ', and this project asks for it' : ''}.</p>
|
|
78
|
+
<div class="row">
|
|
79
|
+
<input type="email" data-fld="email" value="${escapeHtml(pendingEmail)}"
|
|
80
|
+
placeholder="you@example.com" autocomplete="email" spellcheck="false"
|
|
81
|
+
aria-label="Your email address" ${signInBusy ? 'disabled' : ''} />
|
|
82
|
+
<button type="button" class="primary" data-act="code" ${signInBusy ? 'disabled' : ''}>
|
|
83
|
+
${signInBusy ? 'Sending…' : 'Send code'}
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
${problem}
|
|
87
|
+
${login.mode === 'optional'
|
|
88
|
+
? '<div class="after"><button type="button" class="link" data-act="skip">Not now</button></div>'
|
|
89
|
+
: ''}`;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
whoami.innerHTML = `
|
|
94
|
+
<p class="why">We sent a code to <strong>${escapeHtml(pendingEmail)}</strong>. It expires in ten minutes.</p>
|
|
95
|
+
<div class="row">
|
|
96
|
+
<input type="text" class="code" data-fld="code" value="${escapeHtml(pendingCode)}"
|
|
97
|
+
placeholder="123456" inputmode="numeric" autocomplete="one-time-code"
|
|
98
|
+
maxlength="8" aria-label="The code we emailed you" ${signInBusy ? 'disabled' : ''} />
|
|
99
|
+
<button type="button" class="primary" data-act="confirm" ${signInBusy ? 'disabled' : ''}>
|
|
100
|
+
${signInBusy ? 'Checking…' : 'Sign in'}
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
${problem}
|
|
104
|
+
<div class="after">
|
|
105
|
+
<button type="button" class="link" data-act="resend" ${signInBusy ? 'disabled' : ''}>Send it again</button>
|
|
106
|
+
<button type="button" class="link" data-act="signin-back" ${signInBusy ? 'disabled' : ''}>Use another address</button>
|
|
107
|
+
</div>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Redraw, and put the cursor where the reporter is being asked to type. */
|
|
111
|
+
function drawSignIn({ focus = false } = {}) {
|
|
112
|
+
drawWhoami();
|
|
113
|
+
applyGate();
|
|
114
|
+
if (!focus || signInBusy) return;
|
|
115
|
+
const field = whoami.querySelector('[data-fld]');
|
|
116
|
+
if (field) { field.focus(); field.setSelectionRange?.(field.value.length, field.value.length); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Typed values survive a redraw because they are held here, not read back off
|
|
120
|
+
// a node that the next render replaces.
|
|
121
|
+
whoami.addEventListener('input', (e) => {
|
|
122
|
+
const field = e.target.closest('[data-fld]');
|
|
123
|
+
if (!field) return;
|
|
124
|
+
if (field.dataset.fld === 'email') pendingEmail = field.value;
|
|
125
|
+
else pendingCode = field.value;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
whoami.addEventListener('keydown', (e) => {
|
|
129
|
+
if (e.key !== 'Enter' || !e.target.closest('[data-fld]')) return;
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
if (step === 'email') requestCode();
|
|
132
|
+
else confirmCode();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
async function requestCode({ resent = false } = {}) {
|
|
136
|
+
const email = pendingEmail.trim();
|
|
137
|
+
if (!email) { signInError = 'Enter the address to send the code to.'; drawSignIn({ focus: true }); return; }
|
|
138
|
+
|
|
139
|
+
signInBusy = true; signInError = ''; drawSignIn();
|
|
140
|
+
try {
|
|
141
|
+
await client.signIn(email);
|
|
142
|
+
pendingEmail = email;
|
|
143
|
+
if (!resent) pendingCode = '';
|
|
144
|
+
step = 'code';
|
|
145
|
+
} catch (err) {
|
|
146
|
+
signInError = err.message;
|
|
147
|
+
} finally {
|
|
148
|
+
signInBusy = false;
|
|
149
|
+
drawSignIn({ focus: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function confirmCode() {
|
|
154
|
+
const code = pendingCode.trim();
|
|
155
|
+
if (!code) { signInError = 'Enter the code we emailed you.'; drawSignIn({ focus: true }); return; }
|
|
156
|
+
|
|
157
|
+
signInBusy = true; signInError = ''; drawSignIn();
|
|
158
|
+
try {
|
|
159
|
+
const who = await client.confirmSignIn(pendingEmail.trim(), code);
|
|
160
|
+
login.signedIn = who;
|
|
161
|
+
step = 'idle';
|
|
162
|
+
pendingCode = '';
|
|
163
|
+
say('');
|
|
164
|
+
// Signing in is what makes a call possible on most projects — there is
|
|
165
|
+
// now somebody to attribute it to — so the offer is asked for again
|
|
166
|
+
// rather than staying hidden until the panel is reopened.
|
|
167
|
+
ctx.offerCall();
|
|
168
|
+
} catch (err) {
|
|
169
|
+
signInError = err.message;
|
|
170
|
+
} finally {
|
|
171
|
+
signInBusy = false;
|
|
172
|
+
drawSignIn({ focus: step !== 'idle' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
whoami.addEventListener('click', async (e) => {
|
|
177
|
+
const btn = e.target.closest('button[data-act]');
|
|
178
|
+
if (!btn) return;
|
|
179
|
+
const act = btn.dataset.act;
|
|
180
|
+
|
|
181
|
+
if (act === 'signin') { step = 'email'; signInError = ''; drawSignIn({ focus: true }); return; }
|
|
182
|
+
if (act === 'skip') { step = 'idle'; signInError = ''; drawSignIn(); return; }
|
|
183
|
+
// Namespaced: the report screen has a back button of its own, and the two
|
|
184
|
+
// must not be one selector away from each other.
|
|
185
|
+
if (act === 'signin-back') { step = 'email'; signInError = ''; pendingCode = ''; drawSignIn({ focus: true }); return; }
|
|
186
|
+
if (act === 'code') { requestCode(); return; }
|
|
187
|
+
if (act === 'resend') { requestCode({ resent: true }); return; }
|
|
188
|
+
if (act === 'confirm') { confirmCode(); return; }
|
|
189
|
+
if (act === 'signout') {
|
|
190
|
+
signInBusy = true; drawSignIn();
|
|
191
|
+
try {
|
|
192
|
+
await client.signOut();
|
|
193
|
+
} finally {
|
|
194
|
+
login.signedIn = null;
|
|
195
|
+
signInBusy = false;
|
|
196
|
+
// On a project that requires it, signing out puts the form away again
|
|
197
|
+
// and asks — which is what applyGate does off the back of this.
|
|
198
|
+
step = login.mode === 'required' ? 'email' : 'idle';
|
|
199
|
+
drawSignIn({ focus: step !== 'idle' });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/** Re-read the project's setting and this browser's session, and redraw. */
|
|
205
|
+
async function refreshLogin({ announce = false } = {}) {
|
|
206
|
+
const state = await client.loginState();
|
|
207
|
+
login = { mode: state.mode, signedIn: state.signedIn };
|
|
208
|
+
client.lastLoginMode = state.mode;
|
|
209
|
+
// Nothing to type on a project that requires sign-in and has nobody in it.
|
|
210
|
+
if (mustSignIn() && step === 'idle') step = 'email';
|
|
211
|
+
if (login.signedIn) step = 'idle';
|
|
212
|
+
if (announce && state.signedOutReason) say(state.signedOutReason, 'info');
|
|
213
|
+
drawSignIn();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
refreshLogin({ announce: true }).catch(() => { /* a widget that can't ask stays open */ });
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Put the panel back in step after a send or a call was refused: the reason
|
|
220
|
+
* may be that this browser was signed out while the panel sat open.
|
|
221
|
+
*/
|
|
222
|
+
function recheckSignIn() {
|
|
223
|
+
if (login.mode !== 'off') refreshLogin().catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ctx.mustSignIn = mustSignIn;
|
|
227
|
+
ctx.recheckSignIn = recheckSignIn;
|
|
228
|
+
}
|