// Notifications: how many of the viewer's are new, for the header's bell // and the tab's title. The layout sets it from what the server counted, as // it does the zone (zone.svelte.ts), and asks again each time the tab comes // back into view; the notifications page clears it once it has said the // ones it shows are seen. // // A browser this was turned on in also asks every minute while a tab is // open, and shows each new one as the system's notification while the tab // is out of sight. Only this browser keeps that it was turned on: the // browser's permission is its own, not the account's. import type * as getUnreadCount from '#lib/lexicons/dev/knotless/getUnreadCount.ts'; import type * as listNotifications from '#lib/lexicons/dev/knotless/listNotifications.ts'; import { goto } from '$app/navigation'; import { ask } from '#lib/data.ts'; import { itemHref, repoHref, urlPath } from '#lib/format.ts'; import { stateIcon, type IconName } from '#lib/icons.ts'; export const unread = $state({ count: 0 }); /** `title` as a tab shows it: with how many notifications are new before it, as `(3) Title`, when any are. */ export function counted(title: string): string { const n = unread.count; return n ? `(${n > 99 ? '99+' : n}) ${title}` : title; } /** Asks the server again how many are new, keeping the count it had when it cannot say. */ export async function recount(): Promise { const answer = await ask('dev.knotless.getUnreadCount', {}).catch( () => null ); if (answer) unread.count = answer.count; } type Notification = listNotifications.Notification; /** * Its reason as the page words it: one heard of as subscribed is a * comment; one heard of as watching is what its record is, a `comment`, * `pull`, `issue` or `release`. */ function said(n: Notification): string { if (n.reason === 'subscribed') return 'comment'; if (n.reason !== 'watching') return n.reason; const collection = n.uri.split('/')[3] ?? ''; return collection.replace('dev.knotless.', ''); } /** The pull or issue a notification is, or is on, by its URI. */ export function itemOf(n: Notification): string | undefined { switch (said(n)) { case 'comment': case 'checks-failed': case 'ejected': return n.reasonSubject; case 'pull': case 'issue': return n.uri; case 'review-requested': case 'assigned': // An owner's or a collaborator's record says it of the pull or issue. return n.reasonSubject ?? n.uri; case 'mention': return n.reasonSubject ?? n.uri; default: return undefined; } } /** What its author did, between their handle and what it is about, as ` starred `. */ export function leadOf(n: Notification): string { switch (said(n)) { case 'star': return ' starred '; case 'follow': return ' followed you'; case 'pull': return ' opened pull '; case 'issue': return ' opened issue '; case 'review-requested': return ' asked you to review '; case 'assigned': return ' assigned you '; case 'comment': return n.verdict === 'approve' ? ' approved ' : n.verdict === 'changes' ? ' asked for changes on ' : ' commented on '; case 'mention': return ' mentioned you in '; case 'invite': return n.role === 'admin' ? ' invited you to join as an admin' : ' invited you to join as a member'; case 'checks-failed': return ' found failing checks on '; case 'ejected': return ' took '; case 'release': return ` released ${n.title ?? 'a version'} of `; default: return ' did something'; } } /** The kinds the page narrows its list to, besides all: by `kind` as `listNotifications` takes it, name, and what it says when there are none. */ export const KINDS = [ ['mentions', 'Mentions', 'No mentions yet'], ['reviews', 'Reviews', 'No reviews yet'], ['comments', 'Comments', 'No comments yet'], ['pulls-and-issues', 'Pulls and issues', 'No pulls or issues yet'], ['checks', 'Checks', 'No failed checks yet'], ['stars-and-follows', 'Stars and follows', 'No stars or follows yet'] ] as const; /** Whether `n` waits on the asker: an invitation, while it stands, or a review asked for that it has yet to give, on a pull still open. */ export function waiting(n: Notification): boolean { return ( n.reason === 'invite' || (n.reason === 'review-requested' && !n.reviewed && (n.subjectState ?? 'open') === 'open') ); } /** Whether a runner, not a person, did it: its mark stands alone, in place of a picture. */ export function byRunner(n: Notification): boolean { return n.reason === 'checks-failed' || n.reason === 'ejected'; } /** Notifications of one thing that several accounts did: the latest first, and who, each once. */ export type Group = { first: Notification; all: Notification[]; authors: Notification['author'][] }; /** What notifications share to be shown as one: stars of one repository, follows, plain comments on one thread; else none. */ function together(n: Notification): string | undefined { if (n.reason === 'star') return `star ${n.reasonSubject}`; if (n.reason === 'follow') return 'follow'; if (said(n) === 'comment' && !n.verdict && !n.mentioned) return `comment ${n.reasonSubject}`; return undefined; } /** `list`, those that are of one thing put together where the first of them is, new ones apart from those seen. */ export function grouped(list: Notification[]): Group[] { const out: Group[] = []; const at = new Map(); for (const n of list) { const key = together(n); const into = key && at.get(`${n.isRead} ${key}`); if (into) { into.all.push(n); if (!into.authors.some((a) => a.did === n.author.did)) into.authors.push(n.author); continue; } const g = { first: n, all: [n], authors: [n.author] }; if (key) at.set(`${n.isRead} ${key}`, g); out.push(g); } return out; } /** The mark a notification is drawn with on its author's picture, as the feed's are, and the tone it takes. */ export function markOf(n: Notification): [IconName, string] { const reason = said(n); switch (reason) { case 'star': return ['star', 'starred']; case 'follow': return ['personAdd', 'accent']; case 'pull': case 'issue': return [stateIcon(reason, n.subjectState ?? 'open'), toneOf(n.subjectState)]; case 'release': return ['tag', 'accent']; case 'comment': return n.verdict === 'approve' ? ['passed', 'open'] : n.verdict === 'changes' ? ['failed', 'closed'] : ['chat', '']; case 'mention': return ['mention', 'accent']; case 'review-requested': return n.subjectState && n.subjectState !== 'open' ? [stateIcon('pull', n.subjectState), toneOf(n.subjectState)] : ['eye', 'accent']; case 'assigned': return n.subjectState && n.subjectState !== 'open' ? [ stateIcon( n.reasonSubject?.includes('/dev.knotless.issue/') ? 'issue' : 'pull', n.subjectState ), toneOf(n.subjectState) ] : ['person', 'accent']; case 'invite': return ['organization', 'accent']; case 'checks-failed': return ['failed', 'closed']; case 'ejected': return ['queue', 'closed']; default: return ['bell', '']; } } /** What it did to the pull or issue it names, after it, as ` out of the merge queue`; empty for most. */ export function tailOf(n: Notification): string { return n.reason === 'ejected' ? ' out of the merge queue' : ''; } /** The tone of a pull or issue's mark in `state`, as its page draws it. */ function toneOf(state = 'open'): string { return state === 'merged' ? 'merged' : state === 'closed' ? '' : 'open'; } /** The checks it names, in words, as `test and web failed.`; empty when it names none. */ export function failedOf(n: Notification): string { const names = (n.checks ?? []).map((c) => c.workflow); if (!names.length) return ''; const listed = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; return `${listed} failed.`; } /** The page a notification is about: its pull or issue, else its repository, else its author's. */ export function hrefOf(n: Notification): string { const repo = n.repo && repoHref(n.repo.handle, n.repo.name); const item = itemOf(n); if (repo && item) return itemHref(repo, item); if (n.repo && said(n) === 'release') return repoHref(n.repo.handle, n.repo.name, '/releases'); if (repo && n.reason !== 'follow') return repo; return `/${urlPath(n.author.handle)}`; } /** Where this browser keeps that it shows notifications. */ const KEY = 'knotless.browserNotifications'; /** Whether this browser shows notifications: `on`, `off`, `blocked` by the browser, or `unsupported`. */ export type BrowserState = 'on' | 'off' | 'blocked' | 'unsupported'; function kept(): boolean { try { return localStorage.getItem(KEY) === 'on'; } catch { return false; } } function keep(on: boolean) { try { if (on) localStorage.setItem(KEY, 'on'); else localStorage.removeItem(KEY); } catch { // A browser that keeps nothing asks again next time. } } /** Whether this browser shows notifications now. */ export function browserState(): BrowserState { if (typeof window === 'undefined' || !('Notification' in window)) return 'unsupported'; if (Notification.permission === 'denied') return 'blocked'; return Notification.permission === 'granted' && kept() ? 'on' : 'off'; } /** Turns them on or off in this browser, asking it for permission first when it has not given it; gives what it is then. */ export async function setBrowser(on: boolean): Promise { if (on && browserState() === 'off' && Notification.permission !== 'granted') await Notification.requestPermission(); keep(on && Notification.permission === 'granted'); return browserState(); } /** How often a tab asks again while this browser shows notifications. */ const EVERY = 60_000; /** The most shown at once: past that, the bell says how many. */ const SHOWN = 5; function show(n: Notification) { const failed = failedOf(n); const about = n.title ?? n.repo?.name ?? ''; const note = new Notification(`${n.author.handle}${leadOf(n)}${about}${tailOf(n)}`.trim(), { body: failed || undefined, icon: n.author.avatar, tag: n.uri }); note.onclick = () => { window.focus(); void goto(hrefOf(n)); note.close(); }; } /** While this browser shows notifications, asks every minute how many are new, and shows each new one while the tab is out of sight. Gives what stops it. */ export function watch(): () => void { const timer = setInterval(async () => { if (browserState() !== 'on') return; const before = unread.count; await recount(); const fresh = unread.count - before; if (fresh <= 0 || document.visibilityState === 'visible') return; const limit = Math.min(fresh, SHOWN); const answer = await ask('dev.knotless.listNotifications', { limit }).catch(() => null); for (const n of answer?.notifications ?? []) if (!n.isRead) show(n); }, EVERY); return () => clearInterval(timer); }