@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.
Files changed (52) hide show
  1. package/README.md +104 -6
  2. package/dist/bugcatch-call.es.js +587 -0
  3. package/dist/bugcatch-call.es.js.map +1 -0
  4. package/dist/bugcatch-call.umd.js +2 -0
  5. package/dist/bugcatch-call.umd.js.map +1 -0
  6. package/dist/bugcatch-core.es.js +3292 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2531 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1311 -0
  11. package/dist/bugcatch-panel.umd.js.map +1 -0
  12. package/dist/bugcatch.es.js +2 -5625
  13. package/dist/bugcatch.es.js.map +1 -1
  14. package/dist/bugcatch.standalone.umd.js +1619 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +195 -1318
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/accent.js +38 -0
  20. package/src/announcements.js +147 -0
  21. package/src/busy.js +18 -0
  22. package/src/call.js +7 -0
  23. package/src/capture.js +9 -0
  24. package/src/chunks.js +56 -0
  25. package/src/gesture.js +33 -0
  26. package/src/icons.js +18 -0
  27. package/src/identity.js +13 -8
  28. package/src/incoming-call.js +25 -45
  29. package/src/index.d.ts +93 -2
  30. package/src/index.js +164 -42
  31. package/src/lazy.js +23 -0
  32. package/src/notice.js +68 -0
  33. package/src/panel/call.js +242 -0
  34. package/src/panel/compose.js +416 -0
  35. package/src/panel/composer.js +235 -0
  36. package/src/panel/icons.js +36 -0
  37. package/src/panel/index.js +265 -0
  38. package/src/panel/known.js +130 -0
  39. package/src/panel/messages.js +176 -0
  40. package/src/panel/notices.js +34 -0
  41. package/src/panel/picks.js +65 -0
  42. package/src/panel/refs.js +321 -0
  43. package/src/panel/render.js +132 -0
  44. package/src/panel/signin.js +228 -0
  45. package/src/panel/styles.js +1016 -0
  46. package/src/panel/thread.js +494 -0
  47. package/src/session.js +8 -3
  48. package/src/umd/call.js +6 -0
  49. package/src/umd/lazy.js +98 -0
  50. package/src/umd/panel.js +6 -0
  51. package/src/updates.js +97 -34
  52. package/src/widget.js +385 -3376
@@ -0,0 +1,130 @@
1
+ // What is already known: the project's open issues that look like what the
2
+ // reporter is describing, above the form, with "me too" instead of a second
3
+ // report about the same thing.
4
+
5
+ import { escapeHtml, relativeDay } from './render.js';
6
+
7
+ export function mountKnownIssues(ctx) {
8
+ const {
9
+ client, cfg, titleInput, known,
10
+ } = ctx;
11
+
12
+ // The list as it was last fetched, and who has said what about it. Held here
13
+ // rather than re-read from the DOM so a re-render after a join does not have
14
+ // to parse its own markup back out.
15
+ let issues = [];
16
+ let issueError = '';
17
+ let askingFor = null;
18
+ let matchAbort = null;
19
+
20
+ // Asked once when the dialog opens, like the call offer (call.js) and for the
21
+ // same reason: it is one request per opened widget rather than one per page
22
+ // load of every site running this SDK, and most projects will answer with an
23
+ // empty list forever.
24
+ function loadKnownIssues() {
25
+ if (cfg.knownIssues === false) return;
26
+ client.knownIssues()
27
+ .then((rows) => { issues = rows; drawKnown(); })
28
+ .catch(() => { /* a help panel that cannot load is simply not drawn */ });
29
+ }
30
+
31
+ // Re-run as the title is typed, so the panel narrows to what they are
32
+ // actually describing. Debounced, and the previous request is aborted rather
33
+ // than left to land: a fast typist otherwise renders whichever response comes
34
+ // back last, which is not necessarily the one matching the box.
35
+ let matchTimer = null;
36
+ function matchKnown(query) {
37
+ if (cfg.knownIssues === false) return;
38
+ clearTimeout(matchTimer);
39
+ matchAbort?.abort();
40
+ const q = (query || '').trim();
41
+ if (q.length < 3) { loadKnownIssues(); return; }
42
+ matchTimer = setTimeout(() => {
43
+ matchAbort = new AbortController();
44
+ client.matchKnownIssues(q, { signal: matchAbort.signal })
45
+ .then((rows) => {
46
+ // An empty result means "nothing like this is known", which is not
47
+ // the same as "nothing is known" — fall back to the full list rather
48
+ // than blanking a panel the reporter was reading.
49
+ if (rows.length) { issues = rows; drawKnown(); }
50
+ })
51
+ .catch(() => {});
52
+ }, 250);
53
+ }
54
+
55
+ function drawKnown() {
56
+ if (!issues.length) { known.hidden = true; known.innerHTML = ''; return; }
57
+ known.hidden = false;
58
+ known.innerHTML = `
59
+ <p class="known-h">Already reported</p>
60
+ ${issues.map((issue) => `
61
+ <div class="issue ${issue.joined ? 'is-joined' : ''}">
62
+ <span class="who">
63
+ <span class="t">${escapeHtml(issue.title)}</span>
64
+ <span class="meta">${escapeHtml(knownMeta(issue))}</span>
65
+ </span>
66
+ <button class="join" type="button" data-join="${escapeHtml(issue.id)}"
67
+ ${issue.joined ? 'disabled' : ''}>${issue.joined ? 'Following' : 'Me too'}</button>
68
+ </div>
69
+ ${askingFor === issue.id ? `
70
+ <div class="ask">
71
+ <input type="email" class="join-email" placeholder="you@example.com"
72
+ aria-label="Your email, so we can tell you when this is fixed" />
73
+ <button class="ghost" type="button" data-join-confirm="${escapeHtml(issue.id)}">Follow</button>
74
+ </div>` : ''}
75
+ `).join('')}
76
+ ${issueError ? `<p class="err">${escapeHtml(issueError)}</p>` : ''}`;
77
+ }
78
+
79
+ // What a reporter is told about an issue beside its title. Deliberately not
80
+ // its severity: how bad the team thinks this is, published to everybody who
81
+ // opens the widget, is an internal judgement that reads as a promise.
82
+ function knownMeta(issue) {
83
+ const state = issue.status === 'IN_PROGRESS' ? 'Being worked on now' : 'Not fixed yet';
84
+ // The count is dropped entirely at zero rather than written as "0 people",
85
+ // or as a bare "Reported" that says nothing the heading above has not
86
+ // already said. Nobody having pressed the button yet is not a fact about
87
+ // this issue worth a reporter's attention.
88
+ const others = issue.affectedCount === 0
89
+ ? ''
90
+ : ` · ${issue.affectedCount} ${issue.affectedCount === 1 ? 'person has' : 'people have'} said this is happening to them`;
91
+ return `${state} · reported ${relativeDay(issue.since)}${others}`;
92
+ }
93
+
94
+ known.addEventListener('click', async (e) => {
95
+ const start = e.target.closest('[data-join]');
96
+ const confirm = e.target.closest('[data-join-confirm]');
97
+ if (!start && !confirm) return;
98
+
99
+ const bugId = (confirm || start).getAttribute(confirm ? 'data-join-confirm' : 'data-join');
100
+ issueError = '';
101
+
102
+ // An address is what joining buys them — being told when it is fixed — so
103
+ // ask for one when neither a sign-in nor identify() has supplied it, rather
104
+ // than recording somebody nobody can ever write to.
105
+ const email = known.querySelector('.join-email')?.value?.trim()
106
+ || client.signedInAs()?.email
107
+ || client.config.user?.email;
108
+ if (!email) {
109
+ askingFor = bugId;
110
+ drawKnown();
111
+ known.querySelector('.join-email')?.focus();
112
+ return;
113
+ }
114
+
115
+ try {
116
+ const result = await client.meToo(bugId, { email });
117
+ askingFor = null;
118
+ issues = issues.map((issue) => (issue.id === bugId
119
+ ? { ...issue, joined: true, affectedCount: result.affectedCount ?? issue.affectedCount }
120
+ : issue));
121
+ drawKnown();
122
+ } catch (err) {
123
+ issueError = err.message.replace('[BugCatch] ', '');
124
+ drawKnown();
125
+ }
126
+ });
127
+
128
+ loadKnownIssues();
129
+ titleInput.addEventListener('input', () => matchKnown(titleInput.value));
130
+ }
@@ -0,0 +1,176 @@
1
+ // The thread's markup: one entry per event or message, oldest first, grouped
2
+ // into turns the way a chat groups them. Strings in, strings out — the thread
3
+ // screen (thread.js) decides when to draw them and what to keep on screen.
4
+
5
+ import * as ICONS from './icons.js';
6
+ import { SOLVED_PROMPT } from '../agent-issues.js';
7
+ import { humanSize } from '../capture.js';
8
+ import {
9
+ escapeHtml, dayKey, dayLabel, clockTime, relativeDay, initials,
10
+ } from './render.js';
11
+
12
+ /**
13
+ * The whole thread, oldest first.
14
+ *
15
+ * The API hands back one ordered list holding both kinds of entry, which is
16
+ * what keeps a reply sitting under the status change that prompted it. Entries
17
+ * carrying `from` are messages; the rest are things that happened.
18
+ */
19
+ export function threadHtml(timeline) {
20
+ const entries = timeline || [];
21
+ if (!entries.length) return '';
22
+ let day = null;
23
+ let prev = null;
24
+ return entries.map((e) => {
25
+ // A rule with the date on it whenever the calendar day turns, the way a
26
+ // chat marks that the next message came later.
27
+ const d = dayKey(e.at);
28
+ const sep = d && d !== day ? `<div class="day-sep" role="separator">${escapeHtml(dayLabel(e.at))}</div>` : '';
29
+ if (sep) { day = d; prev = null; }
30
+ if (!e.from) { prev = null; return sep + systemHtml(e); }
31
+ const message = {
32
+ from: e.from,
33
+ author: e.reply?.author,
34
+ automated: e.reply?.automated,
35
+ body: e.reply?.body,
36
+ at: e.at,
37
+ attachments: e.reply?.attachments,
38
+ };
39
+ // Consecutive messages from the same voice within a few minutes are one
40
+ // turn: one avatar, one name, the bubbles stacked under it.
41
+ const who = speakerOf(message);
42
+ const cont = prev && prev.who === who
43
+ && Math.abs(new Date(e.at).getTime() - new Date(prev.at).getTime()) < 5 * 60_000;
44
+ prev = { who, at: e.at };
45
+ return sep + messageHtml(message, cont ? 'cont' : '');
46
+ }).join('');
47
+ }
48
+
49
+ /** Which voice a message is in, for grouping: the reporter, the assistant, or a named person. */
50
+ function speakerOf(message) {
51
+ if (message.from === 'reporter') return 'me';
52
+ if (message.automated) return 'assistant';
53
+ return `team:${message.author || ''}`;
54
+ }
55
+
56
+ /**
57
+ * Something that happened, rather than something someone said.
58
+ *
59
+ * `summary` is written by the API, which knows what changed and phrases it for
60
+ * the person who reported it — so the wording here matches the toast and the
61
+ * portal exactly rather than being a third attempt at the same sentence.
62
+ */
63
+ function systemHtml(entry) {
64
+ const when = clockTime(entry.at);
65
+ return `
66
+ <div class="sys k-${escapeHtml(entry.kind)}">
67
+ <span class="dot" aria-hidden="true"></span>${escapeHtml(entry.summary || '')}${when ? `
68
+ <span class="t">· ${escapeHtml(when)}</span>` : ''}
69
+ </div>`;
70
+ }
71
+
72
+ /**
73
+ * One bubble. `state` marks a message that is still in flight, or failed, or
74
+ * one that continues the turn above it.
75
+ *
76
+ * A reply that was sent automatically says so, above the words rather than
77
+ * after them. The person reading it is deciding whether their problem has been
78
+ * understood, and that decision is different when nobody has read the report —
79
+ * so it is not a footnote, and it is not left to be inferred from the tone.
80
+ */
81
+ export function messageHtml(message, state = '') {
82
+ const mine = message.from === 'reporter';
83
+ const body = message.body || '';
84
+ const attachments = message.attachments || [];
85
+ const automated = !mine && Boolean(message.automated);
86
+
87
+ // The text goes in its own element rather than as a bare child of .bubble:
88
+ // it is the only part that wants `white-space: pre-wrap`, and with that rule
89
+ // on the bubble the template's own newlines and indentation render as a
90
+ // blank line above and below every message.
91
+ const text = body ? `<span class="msg-text">${escapeHtml(body)}</span>` : '';
92
+ const files = attachments.length
93
+ ? `<div class="att">${attachments.map(attachmentHtml).join('')}</div>`
94
+ : '';
95
+
96
+ // Named, or labelled, never both and never neither: an automated message has
97
+ // no author to name, and naming one would be the lie the label exists to
98
+ // prevent. The avatar says the same thing at a glance.
99
+ let who = '';
100
+ let avatar = '';
101
+ if (automated) {
102
+ who = '<span class="msg-who auto">Assistant <span class="tag">AI</span></span>';
103
+ avatar = `<span class="av bot orb" aria-hidden="true"></span>`;
104
+ } else if (!mine) {
105
+ const name = message.author || 'Support team';
106
+ who = `<span class="msg-who">${escapeHtml(name)}</span>`;
107
+ avatar = `<span class="av" aria-hidden="true">${escapeHtml(initials(name))}</span>`;
108
+ }
109
+ if (state.includes('cont')) who = '';
110
+
111
+ // The assistant's one answer ends by inviting a correction (check-rules
112
+ // confirmMessage). One tap sends it, and what comes back goes to the person
113
+ // who now owns the report rather than to another automated round — so the
114
+ // wording is "that's right" and "that's not it", not a yes/no question.
115
+ // The Sphoro Agent's answer ends by asking whether it settled things
116
+ // (SOLVED_PROMPT). Yes closes the report; No puts it in front of a person.
117
+ const confirm = automated && /it goes to the same person/i.test(body)
118
+ ? `<div class="quick" role="group" aria-label="Answer">
119
+ <button type="button" data-quick="Yes, this is the problem">${ICONS.check}That's right</button>
120
+ <button type="button" data-quick="No, that is not the problem">That's not it</button>
121
+ </div>`
122
+ : automated && SOLVED_PROMPT.test(body)
123
+ ? `<div class="quick" role="group" aria-label="Did this answer your question?">
124
+ <button type="button" data-quick="Yes, that answers it">${ICONS.check}Yes, that answers it</button>
125
+ <button type="button" data-quick="No, I still need help">No, I still need help</button>
126
+ </div>`
127
+ : '';
128
+
129
+ return `
130
+ <div class="msg-row ${mine ? 'mine' : 'theirs'} ${automated ? 'auto' : ''} ${state}">
131
+ ${avatar}
132
+ ${who}
133
+ <div class="bubble ${body ? '' : 'silent'}">${text}${files}</div>
134
+ ${confirm}
135
+ <span class="msg-when">${escapeHtml(clockTime(message.at) || relativeDay(message.at))}</span>
136
+ </div>`;
137
+ }
138
+
139
+ /** The assistant, thinking: where its next message will appear. */
140
+ export function typingHtml() {
141
+ return `
142
+ <div class="typing-row">
143
+ <span class="av bot orb" aria-hidden="true"></span>
144
+ <span class="msg-who auto">Assistant <span class="tag">AI</span></span>
145
+ <div class="bubble typing" role="img" aria-label="The assistant is working on it"><i></i><i></i><i></i></div>
146
+ </div>`;
147
+ }
148
+
149
+ /**
150
+ * One attachment, rendered as what it is.
151
+ *
152
+ * An image people can see, a voice note they can play and a document they can
153
+ * download are three different things, and collapsing them all to a link is
154
+ * how a chat stops feeling like one. A pending upload has no URL yet, so it
155
+ * falls through to the file row with nothing to click.
156
+ */
157
+ function attachmentHtml(a) {
158
+ const name = escapeHtml(a.name || 'attachment');
159
+ if (!a.url) return `<span class="file"><span class="nm">${name}</span></span>`;
160
+ const url = escapeHtml(a.url);
161
+
162
+ if (a.type === 'SCREENSHOT') {
163
+ return `<a href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}" alt="${name}" loading="lazy"></a>`;
164
+ }
165
+ if (a.type === 'AUDIO') {
166
+ return `<audio controls preload="metadata" src="${url}"></audio>`;
167
+ }
168
+ if (a.type === 'VIDEO') {
169
+ return `<video controls preload="metadata" src="${url}"></video>`;
170
+ }
171
+ return `
172
+ <a class="file" href="${url}" target="_blank" rel="noopener noreferrer" download>
173
+ <span class="nm">${name}</span>
174
+ ${a.size ? `<span class="sz">${escapeHtml(humanSize(a.size))}</span>` : ''}
175
+ </a>`;
176
+ }
@@ -0,0 +1,34 @@
1
+ // What the project's team is telling visitors, at the top of the form: the place
2
+ // to say "we know, it's being fixed" before somebody types it out.
3
+
4
+ import { noticeNode } from '../notice.js';
5
+
6
+ // A panel opened to report a bug, not to read news. A project with more live
7
+ // than this has written a page, and the newest are the ones still true — the
8
+ // API sends them newest first.
9
+ const MOST = 3;
10
+
11
+ export function mountNotices(ctx) {
12
+ const { client, cfg, notices } = ctx;
13
+ let list = [];
14
+
15
+ function draw() {
16
+ const shown = list.slice(0, MOST);
17
+ notices.replaceChildren(...shown.map((a) => noticeNode(a, { where: 'panel', onDismiss })));
18
+ notices.hidden = shown.length === 0;
19
+ }
20
+
21
+ function onDismiss(a) {
22
+ client.dismissAnnouncement(a);
23
+ list = list.filter((x) => x !== a);
24
+ draw();
25
+ // The button pressed has gone; the form is where they were headed.
26
+ ctx.titleInput.focus();
27
+ }
28
+
29
+ if (cfg.announcements !== false && typeof client.announcements === 'function') {
30
+ client.announcements()
31
+ .then((rows) => { if (!ctx.closed) { list = rows; draw(); } })
32
+ .catch(() => { /* a panel with no notices is still a panel that files reports */ });
33
+ }
34
+ }
@@ -0,0 +1,65 @@
1
+ // Questions the assistant answers by itself, offered as one tap above the form.
2
+
3
+ import * as ICONS from './icons.js';
4
+ import { escapeHtml } from './render.js';
5
+
6
+ export function mountPicks(ctx) {
7
+ const {
8
+ client, cfg, titleInput, descInput, picks,
9
+ } = ctx;
10
+
11
+ // One tap instead of a sentence: the report's title becomes the question,
12
+ // and the assistant knows exactly what is being asked — it looks the
13
+ // reporter up, answers from their account, and asks whether that settled
14
+ // it. Tapping the chosen one again lets it go.
15
+ let agentIssues = [];
16
+ let pickedIssue = null;
17
+
18
+ function drawPicks() {
19
+ if (!agentIssues.length) { picks.hidden = true; picks.innerHTML = ''; return; }
20
+ picks.hidden = false;
21
+ // Chips, not cards: they are a shortcut in front of the form, and three
22
+ // two-line cards pushed the form itself off the first screen. The hint is
23
+ // still there — as the chip's tooltip and its accessible description.
24
+ picks.innerHTML = `
25
+ <p class="picks-h">Get an answer right away</p>
26
+ <div class="picks-grid" role="group" aria-label="Common questions">
27
+ ${agentIssues.map((issue, i) => `
28
+ <button type="button" class="pick" data-pick="${escapeHtml(issue.key)}"
29
+ ${issue.hint ? `title="${escapeHtml(issue.hint)}" aria-describedby="bc-pick-h${i}"` : ''}
30
+ aria-pressed="${pickedIssue?.key === issue.key}">
31
+ <span class="t">${escapeHtml(issue.title)}</span>
32
+ ${issue.hint ? `<span class="h sr-only" id="bc-pick-h${i}">${escapeHtml(issue.hint)}</span>` : ''}
33
+ </button>`).join('')}
34
+ </div>
35
+ ${pickedIssue ? `<p class="picks-note">${ICONS.check}<span>Our assistant will check your account and reply here. Add anything else below, then send.</span></p>` : ''}`;
36
+ }
37
+
38
+ picks.addEventListener('click', (e) => {
39
+ const btn = e.target.closest('[data-pick]');
40
+ if (!btn) return;
41
+ const issue = agentIssues.find((i) => i.key === btn.dataset.pick);
42
+ if (!issue) return;
43
+ const titleWasPick = pickedIssue && titleInput.value.trim() === pickedIssue.title;
44
+ if (pickedIssue?.key === issue.key) {
45
+ pickedIssue = null;
46
+ if (titleWasPick) titleInput.value = '';
47
+ } else {
48
+ pickedIssue = issue;
49
+ // Their own words are never overwritten — only an empty title, or the
50
+ // one the previous tap put there.
51
+ if (!titleInput.value.trim() || titleWasPick) titleInput.value = issue.title;
52
+ }
53
+ titleInput.dispatchEvent(new Event('input', { bubbles: true }));
54
+ drawPicks();
55
+ if (pickedIssue) descInput.focus();
56
+ });
57
+
58
+ if (cfg.agentIssues !== false && typeof client.agentIssues === 'function') {
59
+ client.agentIssues()
60
+ .then((rows) => { agentIssues = rows; drawPicks(); })
61
+ .catch(() => { /* no tiles is the safe answer to every failure */ });
62
+ }
63
+
64
+ ctx.pickedIssue = () => pickedIssue;
65
+ }