~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
309 lines · 10.7 KB Raw History
// 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);
}