clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / site / assets / discuss.ts
discuss.ts 13.6K
1// Commenting and approving from a review page, signed in. Pages are static2// and the same for everyone, so the comment form and the Approve button3// (discussion.njk) start hidden; this asks the private server who is4// signed in (/login/whoami, never cached) and shows them to a user. On the5// combined diff, a + beside the line under the pointer (or the line last6// clicked) opens a form for a comment on that line's new side. Posts go to7// /<owner>/<repo>/reviews/<id>/comment and .../approve with the session's8// CSRF token and the tip the page shows, and lib/private-server.ts runs9// forge comment's and forge approve's own code for them. Until the rebuilt10// page comes in (live.ts swaps it in once the build is done), a sent11// comment shows as pending, as typed: its markdown is rendered by the12// build, not here. Drafts outlast that swap, and reloads (sessionStorage).13(() => {14 interface Me {15 user: string;16 csrf: string;17 }18 interface Sent {19 kind: 'comment' | 'approve';20 n?: number;21 text?: string;22 // path:n, for a line comment.23 line?: string;24 }25 let me: Me | null = null;26 // What was sent from this page and isn't on it yet.27 let sent: Sent[] = [];28 // Line comment forms open on this page, by path:n.29 const opened = new Set<string>();3031 const draftKey = (k: string): string => `forge.draft ${location.pathname} ${k}`;32 const draft = (k: string, text?: string): string => {33 try {34 if (text === undefined) return sessionStorage.getItem(draftKey(k)) ?? '';35 if (text) sessionStorage.setItem(draftKey(k), text);36 else sessionStorage.removeItem(draftKey(k));37 } catch {}38 return text ?? '';39 };4041 class Refused extends Error {}42 async function send(url: string, fields: Record<string, string>): Promise<{ message: string; comment?: number }> {43 const r = await fetch(url, {44 method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },45 body: JSON.stringify({ ...fields, csrf: me?.csrf ?? '' }),46 });47 const data = (await r.json().catch(() => ({}))) as { error?: string; message?: string; comment?: number };48 if (!r.ok) throw new Refused(data.error ?? (r.status === 404 ? 'This review is not there any more.' : `The forge said HTTP ${r.status}.`));49 return { message: data.message ?? '', comment: data.comment };50 }51 const failed = (err: unknown): string => (err instanceof Refused ? err.message : "Couldn't reach the forge; your text is still here.");5253 const say = (form: Element | null, text: string, bad = false): void => {54 const s = form?.querySelector<HTMLElement>('.reply-status');55 if (!s) return;56 s.textContent = text;57 s.classList.toggle('bad', bad);58 };59 const busy = (form: Element, on: boolean): void => {60 for (const b of form.querySelectorAll<HTMLButtonElement>('button')) b.disabled = on;61 };6263 // ------------------------------------------------ lines of the combined diff6465 const lines = (): HTMLElement | null => document.getElementById('line-comments');66 const newLine = (row: Element): string => row.querySelectorAll('.ln')[1]?.textContent ?? '';67 // path:n of a row on the new side, or null.68 const whereOf = (row: Element): string | null => {69 const path = row.closest<HTMLElement>('section.file[data-path]')?.dataset.path;70 const n = newLine(row);71 return path && n ? `${path}:${n}` : null;72 };73 function rowOf(where: string): HTMLElement | null {74 const i = where.lastIndexOf(':');75 const file = [...document.querySelectorAll<HTMLElement>('section.file[data-path]')].find((f) => f.dataset.path === where.slice(0, i));76 return [...(file?.querySelectorAll<HTMLElement>('.code a.l') ?? [])].find((l) => newLine(l) === where.slice(i + 1)) ?? null;77 }78 // Under a row, after what is under it already.79 function below(row: HTMLElement, el: HTMLElement): void {80 let at: Element = row;81 while (at.nextElementSibling?.classList.contains('cmts')) at = at.nextElementSibling;82 at.after(el);83 }8485 // The + of the line under the pointer, or tapped last.86 const plus = document.createElement('button');87 plus.type = 'button';88 plus.className = 'addline';89 plus.textContent = '+';90 function place(row: HTMLElement): void {91 const where = me && lines() ? whereOf(row) : null;92 const rows = row.parentElement;93 const code = row.closest('.code');94 if (!where || !rows || !code) return;95 plus.dataset.where = where;96 plus.title = `Comment on ${where}`;97 plus.setAttribute('aria-label', plus.title);98 if (plus.parentElement !== rows) rows.append(plus);99 plus.style.top = `${row.offsetTop + 1}px`;100 plus.style.left = `${code.scrollLeft + 2}px`;101 }102103 function openForm(where: string, focus: boolean): void {104 const row = rowOf(where);105 if (!row || !me) return;106 const had = [...document.querySelectorAll<HTMLElement>('[data-line-form]')].find((x) => x.dataset.lineForm === where);107 if (had) {108 if (focus) had.querySelector('textarea')?.focus();109 return;110 }111 opened.add(where);112 const box = document.createElement('div');113 box.className = 'cmts';114 box.dataset.lineForm = where;115 box.innerHTML = '<form class="linecmt"><div class="where"></div><textarea class="input" name="body" maxlength="32768" placeholder="Comment on this line. Markdown is fine."></textarea>' +116 '<div class="reply-bar"><span class="reply-status" role="status"></span><span class="grow"></span><button type="button" data-cancel>cancel</button><button type="submit" class="primary">comment</button></div></form>';117 box.querySelector('.where')!.textContent = `${where}, as ${me.user}`;118 const text = box.querySelector('textarea')!;119 text.setAttribute('aria-label', `comment on ${where}`);120 text.value = draft(`line ${where}`);121 below(row, box);122 if (focus) text.focus();123 }124 const closeForm = (box: HTMLElement): void => {125 opened.delete(box.dataset.lineForm ?? '');126 box.remove();127 };128129 // ------------------------------------------------ sent, not shown yet130131 // Is it on the page now? Then it is no longer pending.132 const shown = (s: Sent): boolean =>133 s.kind === 'comment' ? !!document.getElementById(`comment-${s.n}`) : !!me && [...document.querySelectorAll<HTMLElement>('ul.approvals li.current')].some((li) => li.dataset.user === me!.user);134135 const pendingHtml = '<div class="comment-head"><b></b> <span class="date">sent, shown here once the page is rebuilt</span></div><div class="text"></div>';136 function pendingBox(tag: string, cls: string, s: Sent): HTMLElement {137 const el = document.createElement(tag);138 el.className = cls;139 el.setAttribute('data-sent', '');140 el.innerHTML = pendingHtml;141 el.querySelector('b')!.textContent = me?.user ?? '';142 el.querySelector('.text')!.textContent = s.text ?? '';143 return el;144 }145146 // Pending comments at the end of the discussion or under their line, and147 // the approval in the form's status line, drawn again after each swap.148 function drawSent(): void {149 sent = sent.filter((s) => !shown(s));150 for (const el of document.querySelectorAll('[data-sent]')) el.remove();151 const form = document.getElementById('reply');152 for (const s of sent) {153 if (s.kind === 'approve') say(form, 'Approved; it shows here once the page is rebuilt.');154 else if (s.line) {155 const row = rowOf(s.line);156 if (!row) continue;157 const box = document.createElement('div');158 box.className = 'cmts';159 box.setAttribute('data-sent', '');160 const c = pendingBox('div', 'cmt pending', s);161 c.removeAttribute('data-sent');162 c.querySelector('.comment-head')!.className = 'cmt-head';163 box.append(c);164 below(row, box);165 } else if (form) {166 let list = document.querySelector('ol.comments');167 if (!list) {168 list = document.createElement('ol');169 list.className = 'rows comments';170 list.setAttribute('data-sent', '');171 form.before(list);172 }173 list.append(pendingBox('li', 'comment pending', s));174 document.getElementById('no-comments')?.setAttribute('hidden', '');175 }176 }177 }178179 // live.ts brings the rebuilt page when the build's event arrives. Should180 // none arrive (the stream is down, say), ask for it now and then.181 let waiting: ReturnType<typeof setTimeout> | undefined;182 function watch(wait = 4000): void {183 clearTimeout(waiting);184 if (!sent.length || wait > 120000) return;185 waiting = setTimeout(() => {186 document.dispatchEvent(new Event('forge:refresh'));187 watch(wait * 2);188 }, wait);189 }190191 // ------------------------------------------------ the page, signed in192193 function setup(): void {194 if (!me) return;195 const form = document.getElementById('reply') as HTMLFormElement | null;196 if (form) {197 form.hidden = false;198 form.querySelector('.who')!.textContent = `as ${me.user}`;199 const text = form.querySelector('textarea')!;200 if (!text.value) text.value = draft('review');201 const approve = form.querySelector<HTMLButtonElement>('[data-approve]');202 if (approve && me.user === form.dataset.author) approve.hidden = true;203 else if (approve && shown({ kind: 'approve' })) {204 approve.disabled = true;205 approve.textContent = 'approved';206 approve.title = 'you approved this tree';207 }208 }209 const hint = lines();210 if (hint) {211 hint.hidden = false;212 hint.textContent = `Commenting as ${me.user}: the + beside a line comments on it.`;213 document.body.classList.add('commenting');214 for (const where of opened) openForm(where, false);215 }216 drawSent();217 }218219 document.addEventListener('mouseover', (e) => {220 const row = (e.target as Element).closest<HTMLElement>('section.file[data-path] .code a.l');221 if (row) place(row);222 });223224 document.addEventListener('click', async (e) => {225 const t = e.target as Element;226 const row = t.closest<HTMLElement>('section.file[data-path] .code a.l');227 // A tap on a line (no pointer to hover with) puts the + there.228 if (row) return place(row);229 if (t.closest('.addline')) {230 e.preventDefault();231 return openForm(plus.dataset.where ?? '', true);232 }233 const cancel = t.closest('[data-line-form] [data-cancel]')?.closest<HTMLElement>('[data-line-form]');234 if (cancel) {235 draft(`line ${cancel.dataset.lineForm}`, '');236 return closeForm(cancel);237 }238 const b = t.closest<HTMLButtonElement>('form#reply [data-approve]');239 const form = b?.closest<HTMLFormElement>('form');240 if (!b || !form || !me) return;241 busy(form, true);242 say(form, 'Approving…');243 try {244 const r = await send(form.dataset.post + 'approve', { at: form.dataset.at ?? '' });245 // Approved before: nothing changes, so nothing to wait for.246 if (/^you already approved/.test(r.message)) say(form, 'You approved this tree already.');247 else {248 sent.push({ kind: 'approve' });249 drawSent();250 watch();251 }252 busy(form, false);253 b.textContent = 'approved';254 b.disabled = true;255 } catch (err) {256 say(form, failed(err), true);257 busy(form, false);258 }259 });260261 // A line's + follows the code when it scrolls sideways.262 document.addEventListener('scroll', (e) => {263 const code = e.target as Element;264 if (code instanceof Element && code.matches('.code') && code.contains(plus)) plus.style.left = `${code.scrollLeft + 2}px`;265 }, true);266267 document.addEventListener('input', (e) => {268 const t = e.target as HTMLTextAreaElement;269 const box = t.closest<HTMLElement>('[data-line-form]');270 if (box) draft(`line ${box.dataset.lineForm}`, t.value);271 else if (t.closest('form#reply')) draft('review', t.value);272 });273274 // Ctrl or Cmd and Enter sends, as in most comment boxes.275 document.addEventListener('keydown', (e) => {276 const t = e.target as HTMLElement;277 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && t.matches('form#reply textarea, form.linecmt textarea')) {278 e.preventDefault();279 t.closest('form')!.requestSubmit();280 }281 });282283 document.addEventListener('submit', async (e) => {284 const form = (e.target as Element).closest<HTMLFormElement>('form#reply, form.linecmt');285 const box = form?.closest<HTMLElement>('[data-line-form]');286 const post = box ? lines()?.dataset : form?.dataset;287 if (!form || !post || !me) return;288 e.preventDefault();289 const field = form.querySelector('textarea')!;290 const text = field.value;291 if (!text.trim()) return say(form, 'Write a comment first.', true);292 const where = box?.dataset.lineForm;293 busy(form, true);294 say(form, 'Sending…');295 try {296 const r = await send(post.post + 'comment', { body: text, at: post.at ?? '', ...(where ? { line: where } : {}) });297 field.value = '';298 draft(where ? `line ${where}` : 'review', '');299 if (box) closeForm(box);300 sent.push({ kind: 'comment', n: r.comment, text, line: where });301 // forge comment's own note, when the review moved on since this page was built.302 say(document.getElementById('reply'), r.message.split('\n').slice(1).join(' '));303 drawSent();304 watch();305 } catch (err) {306 say(form, failed(err), true);307 } finally {308 busy(form, false);309 }310 });311312 // Signed out, or a server without the private server (npm run dev): the313 // controls stay hidden.314 if (!document.getElementById('reply') && !lines()) return;315 fetch('/login/whoami', { cache: 'no-store', credentials: 'same-origin' })316 .then((r) => (r.ok ? r.json() : null))317 .then((w: { user?: string | null; csrf?: string } | null) => {318 if (!w?.user || !w.csrf) return;319 me = { user: w.user, csrf: w.csrf };320 setup();321 // live.ts swaps in rebuilt content.322 document.addEventListener('forge:content', setup);323 })324 .catch(() => {});325})();