johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// 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<void> {
const answer = await ask<typeof getUnreadCount.main>('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<string, Group>();
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<BrowserState> {
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<typeof listNotifications.main>('dev.knotless.listNotifications', {
limit
}).catch(() => null);
for (const n of answer?.notifications ?? []) if (!n.isRead) show(n);
}, EVERY);
return () => clearInterval(timer);
}