@nanocollective/roster 0.1.0-alpha.34 → 0.1.0-alpha.36
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 +51 -1
- package/docs/portal.md +7 -7
- package/package.json +1 -1
- package/templates/portal/css/inbox.css +5 -1
- package/templates/portal/js/app.js +4 -2
- package/templates/portal/js/refresh.js +2 -1
- package/templates/portal/js/state.js +4 -0
- package/templates/portal/js/views/inbox.js +22 -8
package/dist/cli.js
CHANGED
|
@@ -5816,6 +5816,32 @@ function short4(e) {
|
|
|
5816
5816
|
return line.length > 200 ? line.slice(0, 199) + "\u2026" : line;
|
|
5817
5817
|
}
|
|
5818
5818
|
|
|
5819
|
+
// src/lib/notifications.ts
|
|
5820
|
+
async function unreadFor(owner, pages = 5) {
|
|
5821
|
+
const out = /* @__PURE__ */ new Map();
|
|
5822
|
+
for (let page2 = 1; page2 <= pages; page2++) {
|
|
5823
|
+
const res = await ghJson(["api", `notifications?per_page=100&page=${page2}`]);
|
|
5824
|
+
if (!res.ok || !res.data?.length) break;
|
|
5825
|
+
for (const n of res.data) {
|
|
5826
|
+
if (!n.unread || n.repository?.owner?.login?.toLowerCase() !== owner.toLowerCase()) continue;
|
|
5827
|
+
const number = /\/(?:issues|pulls)\/(\d+)$/.exec(n.subject?.url ?? "")?.[1];
|
|
5828
|
+
if (!number || !n.repository?.full_name) continue;
|
|
5829
|
+
out.set(`${n.repository.full_name}#${number}`, {
|
|
5830
|
+
thread: n.id,
|
|
5831
|
+
reason: n.reason,
|
|
5832
|
+
updatedAt: n.updated_at
|
|
5833
|
+
});
|
|
5834
|
+
}
|
|
5835
|
+
if (res.data.length < 100) break;
|
|
5836
|
+
}
|
|
5837
|
+
return out;
|
|
5838
|
+
}
|
|
5839
|
+
async function markRead(thread) {
|
|
5840
|
+
if (!/^\d+$/.test(thread)) return false;
|
|
5841
|
+
const res = await api(`notifications/threads/${thread}`, ["-X", "PATCH"]);
|
|
5842
|
+
return res.ok;
|
|
5843
|
+
}
|
|
5844
|
+
|
|
5819
5845
|
// src/lib/paste.ts
|
|
5820
5846
|
var BEGIN = (path) => `<<<ROSTER FILE ${path}>>>`;
|
|
5821
5847
|
var END = "<<<ROSTER END>>>";
|
|
@@ -7349,6 +7375,24 @@ async function portalCommand(argv) {
|
|
|
7349
7375
|
}).catch((err) => json(res, { labels: [], error: String(err?.message ?? err) }));
|
|
7350
7376
|
return;
|
|
7351
7377
|
}
|
|
7378
|
+
if (url.pathname === "/api/notifications/read" && req.method === "POST") {
|
|
7379
|
+
if (!writeAllowed(req)) {
|
|
7380
|
+
refuseWrite(res);
|
|
7381
|
+
return;
|
|
7382
|
+
}
|
|
7383
|
+
body(req).then(async (raw) => {
|
|
7384
|
+
const payload = JSON.parse(raw || "{}");
|
|
7385
|
+
const ok = await markRead(String(payload.thread ?? ""));
|
|
7386
|
+
if (ok && cache && payload.ref) {
|
|
7387
|
+
const data = JSON.parse(cache.body);
|
|
7388
|
+
for (const i of data.items ?? [])
|
|
7389
|
+
if (`${i.repo}#${i.number}` === payload.ref) i.unread = null;
|
|
7390
|
+
cache = { at: cache.at, body: JSON.stringify(data) };
|
|
7391
|
+
}
|
|
7392
|
+
json(res, { ok });
|
|
7393
|
+
}).catch((err) => json(res, { ok: false, error: String(err?.message ?? err) }));
|
|
7394
|
+
return;
|
|
7395
|
+
}
|
|
7352
7396
|
if (url.pathname === "/api/inbox") {
|
|
7353
7397
|
const fresh = url.searchParams.get("refresh") === "1";
|
|
7354
7398
|
if (!fresh && cache && Date.now() - cache.at < TTL) {
|
|
@@ -7359,9 +7403,15 @@ async function portalCommand(argv) {
|
|
|
7359
7403
|
res.end(cache.body);
|
|
7360
7404
|
return;
|
|
7361
7405
|
}
|
|
7362
|
-
|
|
7406
|
+
const owner = readOrg(w.opsDir, parseYaml).org;
|
|
7407
|
+
Promise.all([fetchInbox(knownRepos()), unreadFor(owner).catch(() => /* @__PURE__ */ new Map())]).then(([data, unread]) => {
|
|
7363
7408
|
const body2 = JSON.stringify({
|
|
7364
7409
|
...data,
|
|
7410
|
+
// Marked from GitHub's notifications, so unread here is unread there.
|
|
7411
|
+
items: data.items.map((i) => ({
|
|
7412
|
+
...i,
|
|
7413
|
+
unread: unread.get(`${i.repo}#${i.number}`) ?? null
|
|
7414
|
+
})),
|
|
7365
7415
|
repos: knownRepos(),
|
|
7366
7416
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7367
7417
|
});
|
package/docs/portal.md
CHANGED
|
@@ -83,13 +83,13 @@ goes away; the repo picker lives on under [Org](#org).
|
|
|
83
83
|
Everything open across the org, from one GraphQL call per repo. Bodies and full timelines come
|
|
84
84
|
down with the list, so opening a thread is a render rather than a request.
|
|
85
85
|
|
|
86
|
-
- **
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
- **Open
|
|
86
|
+
- **The links under Inbox in the sidebar filter it.** All; Unread; each staff member, which
|
|
87
|
+
lists the issues on their own tracker whoever filed them; and Issues, the product repos.
|
|
88
|
+
- **Unread comes from your GitHub notifications.** A thread with activity you have not read has
|
|
89
|
+
a bar on the left and a bold title, and Unread lists only those, with a count. Opening one
|
|
90
|
+
marks it read on GitHub too, and reading it on GitHub clears it here. GitHub only notifies you
|
|
91
|
+
about repos you watch and threads you are part of.
|
|
92
|
+
- **Open or closed.** Open by default: an inbox is what is waiting on
|
|
93
93
|
somebody, and months of finished work mixed into that answers a different question. Closed
|
|
94
94
|
work reaches back 45 days, up to 30 issues and 30 pull requests per repository, and carries
|
|
95
95
|
a shorter timeline than open work because it is there to be read rather than triaged. A
|
package/package.json
CHANGED
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
/* A bottom margin here would collapse against the row's own 16px and change nothing, so the
|
|
58
58
|
extra room is put on the row. General sibling, not adjacent: labels can sit between them. */
|
|
59
59
|
.tconflict ~ .tacts{margin-top:24px}
|
|
60
|
-
.
|
|
60
|
+
.tmetalabels{display:inline-flex;gap:6px;margin-left:10px;vertical-align:middle;font-family:var(--sans)}
|
|
61
61
|
/* A comment is a card with a header strip, so each message has a clear start and end. */
|
|
62
62
|
.cmt{border:1px solid var(--line);border-radius:var(--r-lg);margin:14px 0;background:var(--panel);overflow:hidden}
|
|
63
63
|
.cmt.human{border-color:color-mix(in srgb,var(--accent) 40%,var(--line))}
|
|
@@ -190,3 +190,7 @@ textarea.dropping{border-color:var(--accent);background:color-mix(in srgb,var(--
|
|
|
190
190
|
.tevmore[aria-expanded="true"] .caret{transform:none}
|
|
191
191
|
.tevquiet{border-left:1px solid var(--line);margin-left:9px;padding-left:14px}
|
|
192
192
|
.tevquiet .tev{padding:5px 0;font-size:12px}
|
|
193
|
+
|
|
194
|
+
/* Unread, from GitHub's notifications: a bar on the left and a heavier title. Cleared on open. */
|
|
195
|
+
.irow.unread{box-shadow:inset 3px 0 0 var(--accent)}
|
|
196
|
+
.irow.unread .ititle{font-weight:700;color:var(--ink)}
|
|
@@ -325,6 +325,8 @@ function paintInboxLinks() {
|
|
|
325
325
|
sub.replaceChildren();
|
|
326
326
|
// Inbox itself is everything. Under it: each staff member, then the product repos' issues.
|
|
327
327
|
const links = [
|
|
328
|
+
["", "All", "inbox"],
|
|
329
|
+
["@unread", "Unread", "dot"],
|
|
328
330
|
...S.data.staff.map((s) => [s.handle, s.name, staffIcon(s)]),
|
|
329
331
|
["@issues", "Issues", "issue-open"],
|
|
330
332
|
];
|
|
@@ -332,12 +334,12 @@ function paintInboxLinks() {
|
|
|
332
334
|
const b = el("button", { className: "nav subnav inboxlink" }, [
|
|
333
335
|
icon(glyph, "ic"),
|
|
334
336
|
el("span", { className: "t", textContent: label }),
|
|
337
|
+
...(who === "@unread" ? [el("span", { className: "n", id: "unreadcount" })] : []),
|
|
335
338
|
]);
|
|
336
339
|
b.dataset.who = who;
|
|
337
340
|
b.onclick = () => {
|
|
338
341
|
S.view = "inbox";
|
|
339
|
-
|
|
340
|
-
S.inboxStaff = S.inboxStaff === who ? "" : who;
|
|
342
|
+
S.inboxStaff = who;
|
|
341
343
|
S.inboxFilter = "";
|
|
342
344
|
S.inboxOpen = null;
|
|
343
345
|
render();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { getInbox, getOrg, getSync } from "./api.js";
|
|
11
11
|
import { $, ago, el, esc } from "./dom.js";
|
|
12
12
|
import { render } from "./router.js";
|
|
13
|
-
import { openCount, openPrCount, S } from "./state.js";
|
|
13
|
+
import { openCount, openPrCount, S, unreadCount } from "./state.js";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* The inbox, fetched at most once at a time.
|
|
@@ -55,6 +55,7 @@ export function stampCounts() {
|
|
|
55
55
|
for (const [sel, n] of [
|
|
56
56
|
["#inboxcount", openCount],
|
|
57
57
|
["#prcount", openPrCount],
|
|
58
|
+
["#unreadcount", unreadCount],
|
|
58
59
|
]) {
|
|
59
60
|
const slot = document.querySelector(sel);
|
|
60
61
|
if (!slot) continue;
|
|
@@ -86,6 +86,10 @@ export const S = {
|
|
|
86
86
|
export const openCount = () =>
|
|
87
87
|
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind !== "pr").length;
|
|
88
88
|
|
|
89
|
+
/** Issues with activity you have not read, from GitHub's notifications. */
|
|
90
|
+
export const unreadCount = () =>
|
|
91
|
+
(S.inbox?.items ?? []).filter((i) => i.unread && i.state === "OPEN" && i.kind !== "pr").length;
|
|
92
|
+
|
|
89
93
|
/** The same, for the screen that is only pull requests. */
|
|
90
94
|
export const openPrCount = () =>
|
|
91
95
|
(S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind === "pr").length;
|
|
@@ -235,6 +235,7 @@ function inboxScreen(m, opts) {
|
|
|
235
235
|
// "Issues" is the product repos: every repo a staff member works in.
|
|
236
236
|
const products = new Set(S.data.staff.flatMap((s) => s.worksIn ?? []));
|
|
237
237
|
const issuesOnly = staffPick === "@issues";
|
|
238
|
+
const unreadOnly = staffPick === "@unread";
|
|
238
239
|
const whoseStaff = staffPick && !issuesOnly ? S.data.staff.find((s) => s.handle === staffPick) : null;
|
|
239
240
|
/* The two screens partition the org rather than overlap on it. Pending work is every pull
|
|
240
241
|
request; the inbox is everything else. An inbox that also listed the PRs said the same
|
|
@@ -242,7 +243,9 @@ function inboxScreen(m, opts) {
|
|
|
242
243
|
const mine = S.inbox.items
|
|
243
244
|
/* A staff member's link is their tracker: what is in their repo, whoever filed it. Matching
|
|
244
245
|
on who filed or was named put an issue the CMO filed for the CTO under both of them. */
|
|
245
|
-
.filter((i) =>
|
|
246
|
+
.filter((i) =>
|
|
247
|
+
unreadOnly ? Boolean(i.unread) : issuesOnly ? products.has(i.repo) : !whoseStaff || i.repo === whoseStaff.brain,
|
|
248
|
+
)
|
|
246
249
|
.filter((i) => (i.kind === "pr") === !!opts.prs);
|
|
247
250
|
const isOpen = (i) => i.state === "OPEN";
|
|
248
251
|
const scoped = mine.filter(
|
|
@@ -301,7 +304,7 @@ function inboxScreen(m, opts) {
|
|
|
301
304
|
|
|
302
305
|
for (const i of items) {
|
|
303
306
|
const shutState = i.state === "MERGED" ? "merged" : i.state === "OPEN" ? "" : "closed";
|
|
304
|
-
const b = el("button", { className: "irow" + (shutState ? " shut" : "") });
|
|
307
|
+
const b = el("button", { className: "irow" + (shutState ? " shut" : "") + (i.unread ? " unread" : "") });
|
|
305
308
|
const yours = onAHuman(i);
|
|
306
309
|
const chips = i.labels
|
|
307
310
|
.slice(0, 3)
|
|
@@ -360,22 +363,33 @@ function inboxScreen(m, opts) {
|
|
|
360
363
|
}
|
|
361
364
|
const row = list.querySelector('[data-ref="' + item.repo + "#" + item.number + '"]');
|
|
362
365
|
if (row) markCurrent(list, row);
|
|
366
|
+
// Opening it is reading it: cleared here at once, and on GitHub in the background.
|
|
367
|
+
if (item.unread) {
|
|
368
|
+
const thread = item.unread.thread;
|
|
369
|
+
item.unread = null;
|
|
370
|
+
row?.classList.remove("unread");
|
|
371
|
+
stampCounts();
|
|
372
|
+
post({ thread, ref: item.repo + "#" + item.number }, "/api/notifications/read").catch(() => {});
|
|
373
|
+
}
|
|
363
374
|
|
|
364
375
|
const head = el("div", { className: "thead" });
|
|
365
376
|
head.innerHTML =
|
|
366
377
|
'<div class="meta">' + esc(item.repo) + " · " + (item.kind === "pr" ? "PR " : "") + "#" + item.number +
|
|
367
378
|
' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
|
|
368
|
-
" · " + esc(item.author) + " · " + ago(item.updatedAt) +
|
|
379
|
+
" · " + esc(item.author) + " · " + ago(item.updatedAt) +
|
|
380
|
+
// The labels sit on this line, after the time, rather than taking a line of their own.
|
|
381
|
+
(item.labels.length
|
|
382
|
+
? '<span class="tmetalabels">' +
|
|
383
|
+
item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
|
|
384
|
+
"</span>"
|
|
385
|
+
: "") +
|
|
386
|
+
"</div>" +
|
|
369
387
|
"<h3>" + esc(item.title) + "</h3>" +
|
|
370
388
|
(item.mergeable === "CONFLICTING"
|
|
371
389
|
? '<p class="tconflict">This branch conflicts with its base and cannot be merged ' +
|
|
372
390
|
"until someone rebases or merges the base into it.</p>"
|
|
373
391
|
: "") +
|
|
374
|
-
|
|
375
|
-
? '<div class="row tlabels">' +
|
|
376
|
-
item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
|
|
377
|
-
"</div>"
|
|
378
|
-
: "");
|
|
392
|
+
"";
|
|
379
393
|
head.append(threadActions(item));
|
|
380
394
|
delete viewer.dataset.wait;
|
|
381
395
|
viewer.replaceChildren(head);
|