@nanocollective/roster 0.1.0-alpha.4 → 0.1.0-alpha.5
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/dist/cli.js +8 -5
- package/package.json +1 -1
- package/templates/portal/css/inbox.css +12 -0
- package/templates/portal/css/layout.css +11 -0
- package/templates/portal/index.html +5 -1
- package/templates/portal/js/app.js +40 -0
- package/templates/portal/js/state.js +6 -3
- package/templates/portal/js/views/inbox.js +70 -20
package/dist/cli.js
CHANGED
|
@@ -4260,7 +4260,7 @@ query($owner:String!, $name:String!) {
|
|
|
4260
4260
|
}
|
|
4261
4261
|
pullRequests(states:OPEN, first:60, orderBy:{field:UPDATED_AT, direction:DESC}) {
|
|
4262
4262
|
nodes {
|
|
4263
|
-
number title body url state createdAt updatedAt isDraft
|
|
4263
|
+
number title body url state createdAt updatedAt isDraft mergeable
|
|
4264
4264
|
author { login }
|
|
4265
4265
|
${REACTIONS}
|
|
4266
4266
|
labels(first:12) { nodes { name } }
|
|
@@ -4315,6 +4315,7 @@ async function fetchInbox(repos) {
|
|
|
4315
4315
|
const item = shape(n, full, r.role, "pr");
|
|
4316
4316
|
item.draft = n.isDraft;
|
|
4317
4317
|
item.checks = rollup(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state);
|
|
4318
|
+
item.mergeable = n.mergeable;
|
|
4318
4319
|
items.push(item);
|
|
4319
4320
|
}
|
|
4320
4321
|
const cutoff = Date.now() - CLOSED_DAYS * 864e5;
|
|
@@ -4853,12 +4854,14 @@ async function syncOne(path, dir) {
|
|
|
4853
4854
|
await git4("pull", "--ff-only", "--quiet");
|
|
4854
4855
|
return { ...base2, ahead, behind: 0, dirty, pulled: true };
|
|
4855
4856
|
} catch (err) {
|
|
4856
|
-
return { ...base2, error:
|
|
4857
|
+
return { ...base2, error: reason(err) };
|
|
4857
4858
|
}
|
|
4858
4859
|
}
|
|
4859
|
-
function
|
|
4860
|
-
const
|
|
4861
|
-
const
|
|
4860
|
+
function reason(e) {
|
|
4861
|
+
const err = e;
|
|
4862
|
+
const text = String(err?.stderr ?? "").trim() || String(err?.message ?? e);
|
|
4863
|
+
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
4864
|
+
const line = lines.find((l) => !/^Command failed:/i.test(l)) ?? lines[0] ?? String(e);
|
|
4862
4865
|
return line.length > 180 ? line.slice(0, 179) + "\u2026" : line;
|
|
4863
4866
|
}
|
|
4864
4867
|
|
package/package.json
CHANGED
|
@@ -40,10 +40,22 @@
|
|
|
40
40
|
.ist{display:inline-flex;--ic:12px}
|
|
41
41
|
.ist.closed{color:var(--derived)} .ist.merged{color:var(--derived)}
|
|
42
42
|
.ck.passing{color:var(--accent)} .ck.failing{color:var(--err)} .ck.pending{color:var(--warn)}
|
|
43
|
+
/* Filled rather than outlined like the label chips beside it. A branch that no longer applies
|
|
44
|
+
to its base is the one state where a row can show green checks and still not be mergeable,
|
|
45
|
+
so it has to win the row it is in. */
|
|
46
|
+
.chip.conflict{color:var(--err);border-color:color-mix(in srgb,var(--err) 50%,transparent);
|
|
47
|
+
background:color-mix(in srgb,var(--err) 14%,transparent)}
|
|
43
48
|
|
|
44
49
|
/* ---------- thread ---------- */
|
|
45
50
|
.thead{border-bottom:1px solid var(--line);padding-bottom:16px}
|
|
46
51
|
.thead h3{margin:8px 0 0;font:600 17px/1.35 var(--sans);letter-spacing:-.012em;color:var(--ink)}
|
|
52
|
+
/* The same fact as the chip, said in full, once, at the top of the thread it applies to.
|
|
53
|
+
Tight to the title and a clear gap above the buttons: it is a statement about this pull
|
|
54
|
+
request, not a caption on the controls. At an even 10/16 it read as the latter. */
|
|
55
|
+
.tconflict{margin:4px 0 0;font:12.5px/1.5 var(--sans);color:var(--err)}
|
|
56
|
+
/* A bottom margin here would collapse against the row's own 16px and change nothing, so the
|
|
57
|
+
extra room is put on the row. General sibling, not adjacent: labels can sit between them. */
|
|
58
|
+
.tconflict ~ .tacts{margin-top:24px}
|
|
47
59
|
.tlabels{margin-top:10px}
|
|
48
60
|
.cmt{border-top:1px solid var(--line);padding:16px 0}
|
|
49
61
|
.cmt.first{border-top:0}
|
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
|
|
21
21
|
.sect{padding:14px 20px 6px;font:600 10.5px var(--sans);letter-spacing:.09em;
|
|
22
22
|
text-transform:uppercase;color:var(--ink-faint)}
|
|
23
|
+
/* The Staff heading is also the fold for the list under it, so it is a button — and has to
|
|
24
|
+
go on reading as a heading rather than as a control. The caret and the 20px left padding
|
|
25
|
+
are the staff rows' own, so the two carets line up in one column. */
|
|
26
|
+
.sectfold{display:flex;align-items:center;gap:7px;width:100%;background:none;border:0;
|
|
27
|
+
cursor:pointer;text-align:left;font:600 10.5px var(--sans);letter-spacing:.09em;
|
|
28
|
+
text-transform:uppercase;color:var(--ink-faint)}
|
|
29
|
+
.sectfold:hover{color:var(--ink-dim)}
|
|
30
|
+
.sectfold .caret{display:inline-flex;--ic:12px;flex:none;transition:transform .12s ease}
|
|
31
|
+
.sectfold[aria-expanded="false"] .caret{transform:rotate(-90deg)}
|
|
32
|
+
/* The count is a number, not a heading: mono, and none of the heading's letterspacing. */
|
|
33
|
+
.sectfold .n{font:10.5px var(--mono);letter-spacing:0;text-transform:none;color:var(--ink-faint)}
|
|
23
34
|
/* Flex, not block with a float. The icons are block-level SVGs and the trailing count has to
|
|
24
35
|
sit hard right; `float` does neither of those inside a flex row, and did neither of them
|
|
25
36
|
outside one once the icons arrived. */
|
|
@@ -46,7 +46,11 @@
|
|
|
46
46
|
<span class="ic" data-icon="docs"></span> Docs
|
|
47
47
|
</button>
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
<!-- The heading is the fold. Collapsed, it carries the count, because a hidden list still
|
|
50
|
+
has to say how much it is hiding. -->
|
|
51
|
+
<button class="sect sectfold" id="stafffold" aria-expanded="false" aria-controls="stafflist">
|
|
52
|
+
<span class="caret" data-icon="chevron"></span>Staff<span class="n" id="staffcount" hidden></span>
|
|
53
|
+
</button>
|
|
50
54
|
<div id="stafflist"></div>
|
|
51
55
|
|
|
52
56
|
<div class="sidefoot">
|
|
@@ -89,6 +89,7 @@ export async function boot() {
|
|
|
89
89
|
}
|
|
90
90
|
onRender(render);
|
|
91
91
|
applyHash();
|
|
92
|
+
initStaffFold();
|
|
92
93
|
paintSidebar();
|
|
93
94
|
|
|
94
95
|
$("#inboxnav").onclick = () => { S.view = "inbox"; render(); };
|
|
@@ -180,6 +181,42 @@ function learnPeople() {
|
|
|
180
181
|
*/
|
|
181
182
|
let paintedRoster = null;
|
|
182
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The Staff heading is a fold, and folded it carries the count.
|
|
186
|
+
*
|
|
187
|
+
* It starts folded, so the count is not decoration: a bare "STAFF" heading over nothing is how
|
|
188
|
+
* a loaded org would look if the export came back empty, and those two states have to be
|
|
189
|
+
* tellable apart at a glance. The count sits on the heading rather than beside it so there is
|
|
190
|
+
* one thing to click.
|
|
191
|
+
*/
|
|
192
|
+
let staffOpen = true;
|
|
193
|
+
|
|
194
|
+
function paintStaffFold() {
|
|
195
|
+
const b = $("#stafffold");
|
|
196
|
+
if (b) b.setAttribute("aria-expanded", String(staffOpen));
|
|
197
|
+
const list = $("#stafflist");
|
|
198
|
+
if (list) list.hidden = !staffOpen;
|
|
199
|
+
const n = $("#staffcount");
|
|
200
|
+
if (n) {
|
|
201
|
+
n.textContent = "(" + (S.data?.staff?.length ?? 0) + ")";
|
|
202
|
+
n.hidden = staffOpen;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function initStaffFold() {
|
|
207
|
+
// Folded until you say otherwise: the count on the heading is enough to know the roster is
|
|
208
|
+
// there, and the four org-wide screens above it are where a session starts.
|
|
209
|
+
staffOpen = store("roster.staffopen") === "1";
|
|
210
|
+
paintStaffFold();
|
|
211
|
+
const b = $("#stafffold");
|
|
212
|
+
if (!b) return;
|
|
213
|
+
b.onclick = () => {
|
|
214
|
+
staffOpen = !staffOpen;
|
|
215
|
+
store("roster.staffopen", staffOpen ? "1" : "0");
|
|
216
|
+
paintStaffFold();
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
183
220
|
function paintSidebar() {
|
|
184
221
|
const host = $("#stafflist");
|
|
185
222
|
host.replaceChildren();
|
|
@@ -228,6 +265,9 @@ function paintSidebar() {
|
|
|
228
265
|
|
|
229
266
|
host.append(head, views);
|
|
230
267
|
}
|
|
268
|
+
|
|
269
|
+
// A refresh can hire or retire someone, and the folded heading is where that number shows.
|
|
270
|
+
paintStaffFold();
|
|
231
271
|
}
|
|
232
272
|
|
|
233
273
|
/** Which rows are lit, and which staff member is unfolded. */
|
|
@@ -36,7 +36,9 @@ export const S = {
|
|
|
36
36
|
loadedAt: null,
|
|
37
37
|
|
|
38
38
|
staffHandle: null,
|
|
39
|
-
|
|
39
|
+
/* What is waiting on you, not whose brain you read last. The org-wide screens are where a
|
|
40
|
+
session starts, which is also why the Staff list below them opens folded. */
|
|
41
|
+
view: "inbox",
|
|
40
42
|
/** The inbox filter box. Brain and "what changed" keep their own, below. */
|
|
41
43
|
query: "",
|
|
42
44
|
|
|
@@ -73,9 +75,10 @@ export const S = {
|
|
|
73
75
|
|
|
74
76
|
/* The sidebar badge counts open items, not everything loaded. Once the inbox could show
|
|
75
77
|
closed work the two stopped being the same number, and a badge that says 135 when 34 things
|
|
76
|
-
need you is worse than no badge.
|
|
78
|
+
need you is worse than no badge. Pull requests are excluded for the same reason: they are
|
|
79
|
+
Pending work's, and counting them here would bill you twice for the same pile. */
|
|
77
80
|
export const openCount = () =>
|
|
78
|
-
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN").length;
|
|
81
|
+
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind !== "pr").length;
|
|
79
82
|
|
|
80
83
|
/** The same, for the screen that is only pull requests. */
|
|
81
84
|
export const openPrCount = () =>
|
|
@@ -113,7 +113,7 @@ function inboxScreen(m, opts) {
|
|
|
113
113
|
className: "sub",
|
|
114
114
|
textContent: opts.prs
|
|
115
115
|
? "Work the staff have finished and cannot land themselves."
|
|
116
|
-
: "Everything open across the org.",
|
|
116
|
+
: "Everything open across the org. Pull requests are in Pending work.",
|
|
117
117
|
});
|
|
118
118
|
m.append(sub);
|
|
119
119
|
|
|
@@ -126,13 +126,8 @@ function inboxScreen(m, opts) {
|
|
|
126
126
|
scope.append(
|
|
127
127
|
el("option", { value: "", textContent: "Everything" }),
|
|
128
128
|
el("option", { value: "mine", textContent: "On " + humanLabel() }),
|
|
129
|
-
//
|
|
130
|
-
...(opts.prs
|
|
131
|
-
? []
|
|
132
|
-
: [
|
|
133
|
-
el("option", { value: "decision", textContent: "Decisions" }),
|
|
134
|
-
el("option", { value: "pr", textContent: "Open work (PRs)" }),
|
|
135
|
-
]),
|
|
129
|
+
// A decision is not a PR, so this one is only ever worth offering on the inbox.
|
|
130
|
+
...(opts.prs ? [] : [el("option", { value: "decision", textContent: "Decisions" })]),
|
|
136
131
|
);
|
|
137
132
|
scope.value = opts.prs && S.inboxFilter !== "mine" ? "" : S.inboxFilter;
|
|
138
133
|
|
|
@@ -187,9 +182,9 @@ function inboxScreen(m, opts) {
|
|
|
187
182
|
acted on, because only the already-loaded branch opened one. */
|
|
188
183
|
function restore() {
|
|
189
184
|
if (!S.inboxOpen) return;
|
|
190
|
-
// The two screens share one open thread
|
|
191
|
-
// this
|
|
192
|
-
if (opts.prs
|
|
185
|
+
// The two screens share one open thread, and each holds half the org. Whatever was open on
|
|
186
|
+
// the other one is not in this list, so it is dropped rather than opened beside it.
|
|
187
|
+
if (!!opts.prs !== (S.inboxOpen.kind === "pr")) {
|
|
193
188
|
S.inboxOpen = null;
|
|
194
189
|
writeHash(false);
|
|
195
190
|
return;
|
|
@@ -251,9 +246,12 @@ function inboxScreen(m, opts) {
|
|
|
251
246
|
const onAHuman = (i) => (i.assignees ?? []).some(isHuman);
|
|
252
247
|
|
|
253
248
|
const whoseStaff = S.inboxStaff ? S.data.staff.find((s) => s.handle === S.inboxStaff) : null;
|
|
249
|
+
/* The two screens partition the org rather than overlap on it. Pending work is every pull
|
|
250
|
+
request; the inbox is everything else. An inbox that also listed the PRs said the same
|
|
251
|
+
thing twice and made the badge beside it a number you could not act on. */
|
|
254
252
|
const mine = S.inbox.items
|
|
255
253
|
.filter((i) => belongsTo(i, whoseStaff))
|
|
256
|
-
.filter((i) =>
|
|
254
|
+
.filter((i) => (i.kind === "pr") === !!opts.prs);
|
|
257
255
|
const isOpen = (i) => i.state === "OPEN";
|
|
258
256
|
const scoped = mine.filter(
|
|
259
257
|
S.inboxState === "closed" ? (i) => !isOpen(i) : S.inboxState === "all" ? () => true : isOpen,
|
|
@@ -265,12 +263,17 @@ function inboxScreen(m, opts) {
|
|
|
265
263
|
);
|
|
266
264
|
if (S.inboxFilter === "mine") items = items.filter(onAHuman);
|
|
267
265
|
else if (S.inboxFilter === "decision") items = items.filter((i) => i.labels.includes("decision"));
|
|
268
|
-
|
|
266
|
+
/* `s=pr` was the inbox's "Open work (PRs)" scope, and Pending work is now that screen. An
|
|
267
|
+
old bookmark carrying it is ignored rather than honoured into an empty list. */
|
|
269
268
|
|
|
270
269
|
const open = mine.filter(isOpen);
|
|
271
270
|
const onYou = open.filter(onAHuman).length;
|
|
272
|
-
const prs = open.filter((i) => i.kind === "pr").length;
|
|
273
271
|
const shut = mine.length - open.length;
|
|
272
|
+
/* Pending work owns the pull requests now, so this is a pointer to that screen rather than
|
|
273
|
+
a count of anything on this list. Scoped the same way, or the two would not add up. */
|
|
274
|
+
const prs = S.inbox.items.filter(
|
|
275
|
+
(i) => i.kind === "pr" && isOpen(i) && belongsTo(i, whoseStaff),
|
|
276
|
+
).length;
|
|
274
277
|
const where = whoseStaff
|
|
275
278
|
? " for <b>" + esc(whoseStaff.name) + "</b>"
|
|
276
279
|
: " across " + (S.inbox.repos ?? []).length + " repos";
|
|
@@ -286,7 +289,7 @@ function inboxScreen(m, opts) {
|
|
|
286
289
|
(failing ? " · " + failing + " failing" : "") +
|
|
287
290
|
(S.inboxState === "all" ? " · " + shut + " closed or merged" : "")
|
|
288
291
|
: open.length + " open" + where + " · <b>" + onYou + " on " +
|
|
289
|
-
esc(humanLabel()) + "</b> · " + prs + "
|
|
292
|
+
esc(humanLabel()) + "</b> · " + prs + " in Pending work" +
|
|
290
293
|
(S.inboxState === "all" ? " · " + shut + " closed" : "")) +
|
|
291
294
|
(S.inbox.fetchedAt ? ' <span class="meta">· checked ' + ago(S.inbox.fetchedAt) + "</span>" : "");
|
|
292
295
|
|
|
@@ -324,6 +327,12 @@ function inboxScreen(m, opts) {
|
|
|
324
327
|
'<span class="num">' + (i.kind === "pr" ? "PR " : "") + "#" + i.number + "</span>" +
|
|
325
328
|
(i.checks && i.checks !== "none"
|
|
326
329
|
? '<span class="ck ' + i.checks + '">' + checkGlyph(i.checks) + "</span>" : "") +
|
|
330
|
+
/* Green checks on a branch that no longer applies is the one case where the row
|
|
331
|
+
reads as ready and is not. It has to say so here, not three clicks in. */
|
|
332
|
+
(i.mergeable === "CONFLICTING"
|
|
333
|
+
? '<span class="chip conflict" title="This branch conflicts with its base">' +
|
|
334
|
+
"conflicts</span>"
|
|
335
|
+
: "") +
|
|
327
336
|
// How much conversation is on a thread, which is most of what tells a live one from
|
|
328
337
|
// something that was filed and never answered.
|
|
329
338
|
(i.comments.length
|
|
@@ -365,6 +374,10 @@ function inboxScreen(m, opts) {
|
|
|
365
374
|
' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
|
|
366
375
|
" · " + esc(item.author) + " · " + ago(item.updatedAt) + "</div>" +
|
|
367
376
|
"<h3>" + esc(item.title) + "</h3>" +
|
|
377
|
+
(item.mergeable === "CONFLICTING"
|
|
378
|
+
? '<p class="tconflict">This branch conflicts with its base and cannot be merged ' +
|
|
379
|
+
"until someone rebases or merges the base into it.</p>"
|
|
380
|
+
: "") +
|
|
368
381
|
(item.labels.length
|
|
369
382
|
? '<div class="row tlabels">' +
|
|
370
383
|
item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
|
|
@@ -527,8 +540,11 @@ function inboxScreen(m, opts) {
|
|
|
527
540
|
b.onclick = async () => {
|
|
528
541
|
b.disabled = true;
|
|
529
542
|
try {
|
|
530
|
-
|
|
531
|
-
|
|
543
|
+
// Same reason as the header's Reply: the wait is after the box closes, not during it.
|
|
544
|
+
const r = await openReply(item, f, () => {
|
|
545
|
+
b.textContent = "Replying…";
|
|
546
|
+
});
|
|
547
|
+
b.textContent = r ? (r.warning ? "sent, with a warning" : "sent") : "Reply";
|
|
532
548
|
} catch (e) {
|
|
533
549
|
b.textContent = e.message;
|
|
534
550
|
}
|
|
@@ -590,24 +606,43 @@ function inboxScreen(m, opts) {
|
|
|
590
606
|
/* One button, because there was no way to tell the two apart. Reply reaches whoever you
|
|
591
607
|
named in it; see `openReply`. */
|
|
592
608
|
const reply = el("button", { className: "ghbtn primary", textContent: "Reply" });
|
|
609
|
+
|
|
610
|
+
/* Posting a comment is a round trip to GitHub through `gh`, and on a cold connection that
|
|
611
|
+
is seconds of a button that still says "Reply" and still takes clicks. Saying nothing
|
|
612
|
+
reads as a page that ignored you, which is how the same comment gets sent twice. */
|
|
613
|
+
const sending = (on) => {
|
|
614
|
+
busy(on, on ? "sending…" : "");
|
|
615
|
+
reply.textContent = on ? "Replying…" : "Reply";
|
|
616
|
+
};
|
|
617
|
+
|
|
593
618
|
reply.onclick = async () => {
|
|
594
619
|
let r;
|
|
595
620
|
try {
|
|
596
|
-
r = await openReply(item, null);
|
|
621
|
+
r = await openReply(item, null, () => sending(true));
|
|
597
622
|
} catch (e) {
|
|
623
|
+
sending(false);
|
|
598
624
|
failed(e);
|
|
599
625
|
return;
|
|
600
626
|
}
|
|
601
|
-
|
|
627
|
+
// Closed the box without sending: nothing ever started, so there is nothing to put back.
|
|
628
|
+
if (!r) {
|
|
629
|
+
sending(false);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
602
632
|
/* Repaint only when something was said on this thread, which is always true here: `ask`
|
|
603
633
|
posts the comment too. A warning means the tracker issue went up and the comment did
|
|
604
634
|
not, and then the status line is the only thing that says so. */
|
|
605
635
|
if (r.warning) {
|
|
636
|
+
sending(false);
|
|
606
637
|
status.className = "meta err";
|
|
607
638
|
status.textContent = r.warning;
|
|
608
639
|
return;
|
|
609
640
|
}
|
|
641
|
+
/* Held across the reload as well as the post. The reply is not on screen until the
|
|
642
|
+
thread repaints, and a button that said "Reply" again before then would be claiming
|
|
643
|
+
it was done over a thread that still did not show it. */
|
|
610
644
|
await reloadThread(item);
|
|
645
|
+
sending(false);
|
|
611
646
|
};
|
|
612
647
|
buttons.push(reply);
|
|
613
648
|
|
|
@@ -651,6 +686,12 @@ function inboxScreen(m, opts) {
|
|
|
651
686
|
own settings. The server reads it from there. */
|
|
652
687
|
if (item.kind === "pr" && item.state === "OPEN") {
|
|
653
688
|
const merge = el("button", { className: "ghbtn", textContent: "Merge" });
|
|
689
|
+
/* A conflicted branch cannot be merged by anyone, so the button says why instead of
|
|
690
|
+
spending a round trip to come back with GitHub's version of the same sentence. */
|
|
691
|
+
if (item.mergeable === "CONFLICTING") {
|
|
692
|
+
merge.disabled = true;
|
|
693
|
+
merge.title = "This branch conflicts with its base";
|
|
694
|
+
}
|
|
654
695
|
merge.onclick = async () => {
|
|
655
696
|
if (!confirm("Merge " + item.repo + " #" + item.number + " into its base branch?")) return;
|
|
656
697
|
busy(true, "merging…");
|
|
@@ -714,7 +755,15 @@ function inboxScreen(m, opts) {
|
|
|
714
755
|
* @param anchor the file this is about, when it was opened from a diff. Its hunk rides along.
|
|
715
756
|
* @returns the result of the write, or null if nothing was written.
|
|
716
757
|
*/
|
|
717
|
-
|
|
758
|
+
/**
|
|
759
|
+
* @param onSend Called once, the moment the box is submitted and the writing starts.
|
|
760
|
+
*
|
|
761
|
+
* The dialog and the send are one `await` to whoever called this, so a caller cannot tell the
|
|
762
|
+
* two apart by itself — and the two want opposite things from a button. While the box is open
|
|
763
|
+
* you are still typing and the button has to stay as it was; once it closes there is a request
|
|
764
|
+
* in flight against GitHub that can take seconds, and that is what needs saying.
|
|
765
|
+
*/
|
|
766
|
+
async function openReply(item, anchor, onSend) {
|
|
718
767
|
const key = item.repo + "#" + item.number + (anchor ? " · " + anchor.path : "");
|
|
719
768
|
let deaf = null;
|
|
720
769
|
|
|
@@ -737,6 +786,7 @@ async function openReply(item, anchor) {
|
|
|
737
786
|
},
|
|
738
787
|
});
|
|
739
788
|
if (!body) return null;
|
|
789
|
+
onSend?.();
|
|
740
790
|
|
|
741
791
|
/* One press, both writes. `ask` posts the comment itself when told to, so these are not two
|
|
742
792
|
requests racing: the tracker issue goes up first because it is the half that actually
|