chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Releases keep their start across levels, and a notification mutes its thread
crates/knotless-serve/src/index.rs+26−10
| @@ -687,6 +687,10 @@ pub(crate) fn connect(path: &Path) -> rusqlite::Result<(Connection, bool)> { | ||
db.execute_batch("ALTER TABLE push ADD COLUMN pusher TEXT")?; | ||
} | ||
db.execute_batch("CREATE INDEX IF NOT EXISTS push_pusher ON push (pusher, created_at)")?; | ||
// When a choice began to hear of a repository's releases, which a | ||
// change between all it does and its releases alone keeps: added in | ||
// place, as the table outlives a rebuild, and kept if it was there. | ||
let _ = db.execute_batch("ALTER TABLE subscription ADD COLUMN releases_at TEXT"); | ||
// Comments, by what they are on, from the forge table, once: an | ||
// index from before had them only there. One whose forge table is | ||
// of another shape gets them as they come. | ||
| @@ -1945,8 +1949,9 @@ impl Index { | ||
-- What it chose to hear of: a repository it watches, by | ||
-- owner and key, or a pull or an issue it subscribed to or | ||
-- muted, by URI; each since when it chose. | ||
subs(subject, did, key, level, at) AS (SELECT value ->> '$.subject', | ||
value ->> '$.did', value ->> '$.key', value ->> '$.level', value ->> '$.at' | ||
subs(subject, did, key, level, at, releases) AS (SELECT value ->> '$.subject', | ||
value ->> '$.did', value ->> '$.key', value ->> '$.level', value ->> '$.at', | ||
value ->> '$.releases' | ||
FROM json_each(?7)), | ||
-- The reasons it turned off. | ||
off(reason) AS (SELECT j.value FROM pref p, json_each(p.value, '$.off') j | ||
| @@ -2130,7 +2135,7 @@ impl Index { | ||
(rl.value ->> '$.package') || ' ' || (rl.value ->> '$.version'), rl.created_at, | ||
NULL, NULL, NULL, rl.value ->> '$.notes', NULL | ||
FROM subs s CROSS JOIN release rl ON rl.did = s.did AND rl.repo_key = s.key | ||
AND rl.created_at > s.at | ||
AND rl.created_at > coalesce(s.releases, s.at) | ||
WHERE s.level IN ('all', 'releases') | ||
UNION ALL | ||
-- The checks that failed at the latest run of each workflow | ||
| @@ -2324,7 +2329,7 @@ impl Index { | ||
pub fn subscriptions(&self, did: &str) -> rusqlite::Result<Vec<Subscription>> { | ||
let db = self.readers.take(&self.db); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT subject, level, at FROM subscription WHERE did = ?1 | ||
"SELECT subject, level, at, releases_at FROM subscription WHERE did = ?1 | ||
ORDER BY at DESC, subject", | ||
)?; | ||
let rows = stmt.query_map([did], |row| { | ||
| @@ -2332,6 +2337,7 @@ impl Index { | ||
subject: row.get(0)?, | ||
level: row.get(1)?, | ||
at: row.get(2)?, | ||
releases_at: row.get(3)?, | ||
}) | ||
}); | ||
rows?.collect() | ||
| @@ -2348,7 +2354,9 @@ impl Index { | ||
/// Keeps that `did` hears of `subject` as `level` says, from `at`: | ||
/// [`PARTICIPATING`] forgets what it chose, so it hears as its part in | ||
/// it says. Choosing the level it chose already keeps it as of when it | ||
/// first did. False, keeping nothing, for a new choice past `max`. | ||
/// first did; and moving between all of a repository and its releases | ||
/// alone keeps its releases as of when it began to hear of them. False, | ||
/// keeping nothing, for a new choice past `max`. | ||
pub fn subscribe( | ||
&self, | ||
did: &str, | ||
| @@ -2372,8 +2380,12 @@ impl Index { | ||
return Ok(false); | ||
} | ||
db.execute( | ||
"INSERT INTO subscription VALUES (?1, ?2, ?3, ?4) | ||
ON CONFLICT (did, subject) DO UPDATE SET level = excluded.level, at = excluded.at | ||
"INSERT INTO subscription (did, subject, level, at, releases_at) | ||
VALUES (?1, ?2, ?3, ?4, CASE WHEN ?3 IN ('all', 'releases') THEN ?4 END) | ||
ON CONFLICT (did, subject) DO UPDATE SET level = excluded.level, at = excluded.at, | ||
releases_at = CASE WHEN excluded.level NOT IN ('all', 'releases') THEN NULL | ||
WHEN level IN ('all', 'releases') THEN coalesce(releases_at, at) | ||
ELSE excluded.at END | ||
WHERE level != excluded.level", | ||
[did, subject, level, at], | ||
)?; | ||
| @@ -4211,6 +4223,9 @@ pub struct Subscription { | ||
pub level: String, | ||
/// When it chose. | ||
pub at: String, | ||
/// For a repository, when it began to hear of its releases, which may | ||
/// be before it chose the level it has now. | ||
pub releases_at: Option<String>, | ||
} | ||
| ||
impl Subscription { | ||
| @@ -4229,7 +4244,7 @@ impl Subscription { | ||
fn row(&self) -> Value { | ||
let (did, key) = self.repo_of().unzip(); | ||
serde_json::json!({ "subject": self.subject, "did": did, "key": key, | ||
"level": self.level, "at": self.at }) | ||
"level": self.level, "at": self.at, "releases": self.releases_at }) | ||
} | ||
} | ||
| ||
| @@ -4848,9 +4863,10 @@ mod tests { | ||
}; | ||
assert_eq!(kind("comments"), ["9", "3"]); | ||
assert_eq!(kind("pulls-and-issues"), ["2"]); | ||
// Its releases alone; then nothing of it, but a review asked of it. | ||
// Its releases alone, still from when it began to hear of them; | ||
// then nothing of it, but a review asked of it. | ||
index | ||
.subscribe("did:c", &watched, "releases", &at(2), 9) | ||
.subscribe("did:c", &watched, "releases", &at(6), 9) | ||
.unwrap(); | ||
assert_eq!( | ||
reasons("did:c"), | ||
docs/design/serve.md+5−2
| @@ -1157,7 +1157,9 @@ it ignores gives it nothing. A pull or an issue it subscribed to gives | ||
it every comment (`subscribed`), and one it muted nothing, mentions | ||
included. A review asked of it is heard of through either, as it waits | ||
on the account. Each choice counts from when it was made, so watching | ||
a busy repository does not bring its past along, and each is a reason | ||
a busy repository does not bring its past along; a repository's releases | ||
count from when the account began to hear of them, which moving between | ||
`all` and `releases` keeps (`releases_at`). Each is a reason | ||
of its own only for what no other reason gives: a comment on a thread | ||
the account wrote is still `comment`, and a pull on a repository it | ||
looks after still `pull`. Like preferences, the choices are the | ||
| @@ -1175,7 +1177,8 @@ what it chose of the thread and why it hears of it otherwise | ||
(`notifiedFor`): as its author, as one who commented, or as watching | ||
its repository. The pages' Watch menu and the thread's Subscribe | ||
button set them; Unsubscribe on a thread the account hears of for its | ||
part in it mutes it. | ||
part in it mutes it, and so does Mute on a notification of a pull or an | ||
issue, but one asking for a review. | ||
| ||
### Preferences | ||
| ||
docs/reference/api.md+1−1
| @@ -624,7 +624,7 @@ from this site` unless its `Origin` is the site's own. | ||
| `POST /xrpc/dev.knotless.blockActor` | `{subject, repo}`, a handle or DID, and optionally the `at://` URI of one of the account's own repositories: blocks it from all of them or from that one, answering `{subject, blocking}`; 400 for the account itself, another's repository or a URI that is no repository's | | ||
| `POST /xrpc/dev.knotless.unblockActor` | `{subject, repo}`: takes those blocks back, answering the same | | ||
| `POST /xrpc/dev.knotless.putPreferences` | `{preferences}`: each takes the place of its kind whole, and the rest stay, answering all of them as `getPreferences`. A `#notificationPref`'s reasons not named are on, and `followedOnly` absent is false; an `#appearancePref` with no `theme` follows the system, and logging in to the pages copies its theme to the browser's `theme` cookie, and for `custom`, its `palette` to the `palette` cookie, and its `night`'s to `theme-night` and `palette-night`. A `#themesPref` holds 50 at most, each a copy, so choosing a theme leaves it be. With a service token too, and with the cookie only from the site; 400 for a kind there is none of, a reason there is none of, a theme not named in `a-z`, `0-9` and `-` (64 at most), `custom` with no palette, a palette not as its lexicon says (its contrast is not checked here), a `source` that is no `dev.knotless.theme`, or an added theme whose `uri` is no `dev.knotless.theme`, whose palette is not as its lexicon says, whose `name` is over 64 characters, whose `creator` is no DID or whose `addedAt` is no time | | ||
| `POST /xrpc/dev.knotless.putSubscription` | `{subject, level}`: what the asker hears of a repository (`all`, `releases` or `ignore`) or a pull or an issue (`subscribed` or `muted`), from now; `participating` forgets the choice. Choosing the level it has keeps it as of then. Answers `{subject, level, createdAt}`. With a service token too, and with the cookie only from the site; 400 for a subject that is no repository, pull or issue, a level it cannot take, or past 1,000 choices; 404 for a repository the asker cannot read | | ||
| `POST /xrpc/dev.knotless.putSubscription` | `{subject, level}`: what the asker hears of a repository (`all`, `releases` or `ignore`) or a pull or an issue (`subscribed` or `muted`), from now; `participating` forgets the choice. Choosing the level it has keeps it as of then, and moving a repository between `all` and `releases` keeps its releases as of when it began to hear of them. Answers `{subject, level, createdAt}`. With a service token too, and with the cookie only from the site; 400 for a subject that is no repository, pull or issue, a level it cannot take, or past 1,000 choices; 404 for a repository the asker cannot read | | ||
| `POST /xrpc/dev.knotless.createRepo` | `{name, description?, defaultBranch?, private?}`: creates a repository in the account logged in, public or with `private` a space of its own, as `knotless create [--private]` does, answering `{uri, name}`; 400 for a name no repository can have, one the account has already, or a private one on a PDS without spaces | | ||
| `POST /xrpc/dev.knotless.updateRepo` | `{repo, description?, links?, topics?, defaultBranch?}`: as `knotless describe`, answering `{description, links, topics, defaultBranch}` as they now stand; 400 for a link that is not an http or https address, or more than five, and for a topic that is not letters, digits and dashes, or more than twenty | | ||
| `POST /xrpc/dev.knotless.renameRepo` | `{repo, name}`: as `knotless rename`, answering `{uri, name}` | | ||
harness/e2e/flows.spec.mjs+14−0
| @@ -410,6 +410,20 @@ test('seeing notifications', async () => { | ||
await expect(page.getByRole('link', { name: /^Notifications, \d+ new$/ })).toBeVisible() | ||
await go(page, '/notifications') | ||
await anew(page, (expect) => expect(page.getByRole('link', { name: 'Notifications', exact: true })).toBeVisible()) | ||
// A pull or an issue muted from its row, and that undone. | ||
const rows = page.locator('.notifications li') | ||
const at = await rows.evaluateAll((ls) => ls.findIndex((l) => l.querySelector('.row-mute'))) | ||
expect(at).toBeGreaterThanOrEqual(0) | ||
const row = rows.nth(at) | ||
await row.hover() | ||
await row.locator('.row-mute').click() | ||
await expect(row.getByRole('status')).toContainText(/^You muted this (issue|pull)\./) | ||
// Read back as the page asks, through the gateway only the browser reaches. | ||
const chosen = () => page.evaluate(async () => (await (await fetch('/xrpc/dev.knotless.listSubscriptions')).json()).subscriptions.map((s) => s.level)) | ||
expect(await chosen()).toContain('muted') | ||
await row.getByRole('button', { name: 'Undo' }).click() | ||
await expect(row.locator('.row-mute')).toHaveCount(1) | ||
expect(await chosen()).not.toContain('muted') | ||
}) | ||
| ||
test('hiding a comment as the owner', async () => { | ||
harness/shots/fixtures.json+2−0
| @@ -14,6 +14,7 @@ | ||
"org": "/textkit.test", | ||
"org-members": "/textkit.test?tab=members", | ||
"repository": "/alice.test/tally", | ||
"repository-watch": "/alice.test/tally", | ||
"directory": "/alice.test/tally/tree/main/src", | ||
"file": "/alice.test/tally/blob/main/src/lib.rs", | ||
"blame": "/alice.test/tally/blame/main/src/lib.rs", | ||
| @@ -32,6 +33,7 @@ | ||
"issues": "/alice.test/tally/issues", | ||
"issues-closed": "/alice.test/tally/issues?state=closed", | ||
"issue": "/alice.test/tally/issue/did:plc:55j6e4wxzlzndxm4nov2lz7x/3myvwhak63fo4", | ||
"issue-subscribe": "/alice.test/tally/issue/did:plc:55j6e4wxzlzndxm4nov2lz7x/3myvwhak63fo4", | ||
"issue-closed": "/alice.test/tally/issue/did:plc:55j6e4wxzlzndxm4nov2lz7x/3myvwhaqw6os3", | ||
"issue-fixed": "/alice.test/tally/issue/did:plc:55j6e4wxzlzndxm4nov2lz7x/3myvwh7kezpru", | ||
"fork": "/bob.test/tally", | ||
harness/shots/fixtures.mjs+26−1
| @@ -48,6 +48,22 @@ export async function front({ recorded = {}, from, build } = {}) { | ||
const recorded = await answer(url).catch(() => null) | ||
return recorded?.status === 200 ? recorded : json(await kept(url)) | ||
} | ||
// A repository and a pull or an issue, with what the account has to do | ||
// with it, which the server answers anyone logged in: nothing chosen, | ||
// so it hears of what it takes part in. Of a pull or an issue, its | ||
// author may close it, and the repository's owner close and hide it. | ||
if (url.startsWith('/xrpc/dev.knotless.getRepo?') || url.startsWith('/xrpc/dev.knotless.getThread?')) { | ||
const recorded = await answer(url).catch(() => null) | ||
if (recorded?.status !== 200) return null | ||
const body = JSON.parse(recorded.body) | ||
if (url.startsWith('/xrpc/dev.knotless.getRepo?')) return { ...recorded, body: JSON.stringify({ viewer: {}, ...body }) } | ||
const profile = await answer(`/xrpc/dev.knotless.getProfile?actor=${encodeURIComponent(handle)}`) | ||
const { did } = JSON.parse(profile.body) | ||
const owner = new URLSearchParams(url.split('?')[1]).get('repo')?.startsWith(`at://${handle}/`) | ||
const author = body.item?.author?.did === did | ||
const viewer = author ? { setState: true, notifiedFor: 'author' } : owner ? { setState: true, hide: true } : {} | ||
return { ...recorded, body: JSON.stringify({ viewer, ...body }) } | ||
} | ||
if (url !== '/xrpc/dev.knotless.getViewer') return null | ||
const profile = await answer(`/xrpc/dev.knotless.getProfile?actor=${encodeURIComponent(handle)}`) | ||
if (!profile) return null | ||
| @@ -97,7 +113,16 @@ export async function front({ recorded = {}, from, build } = {}) { | ||
const server = createServer(async (req, res) => { | ||
if (answered(req.url)) { | ||
const as = req.headers.cookie?.match(/(?:^|;\s*)shots_as=([^;]+)/)?.[1] | ||
const a = (as && (await viewing(req.url, decodeURIComponent(as)))) || (await answer(req.url).catch(() => null)) | ||
// A choice of what to hear of, as the server keeps it, for what one | ||
// signed in clicks to look at. | ||
if (as && req.url === '/xrpc/dev.knotless.putSubscription') { | ||
let input = '' | ||
for await (const chunk of req) input += chunk | ||
res.writeHead(200, { 'content-type': 'application/json' }) | ||
res.end(JSON.stringify({ ...JSON.parse(input), createdAt: now })) | ||
return | ||
} | ||
const a =(as && (await viewing(req.url, decodeURIComponent(as)))) || (await answer(req.url).catch(() => null)) | ||
if (!a) { | ||
res.writeHead(404, { 'content-type': 'application/json' }) | ||
res.end(JSON.stringify({ message: `nothing recorded for ${req.url}` })) | ||
harness/shots/views.spec.mjs+2−2
| @@ -22,8 +22,8 @@ const unchanged = new Set(read('unchanged.json', [])) | ||
const views = read('views.json', {}) | ||
| ||
const names = [ | ||
'home', 'search', 'start', 'terms', 'privacy', 'guidelines', 'profile', 'profile-fork', 'profile-repos', 'profile-activity', 'repository', 'directory', 'file', 'markdown', 'markdown-plain', 'diagram', 'image', 'commits', 'commit', | ||
'commit-merged', 'pulls', 'pulls-closed', 'pull', 'pull-open', 'issues', 'issues-closed', 'issue', 'issue-closed', 'issue-fixed', 'fork', 'empty', 'missing', 'check', 'queue', 'checks', 'pull-queued', 'pull-ejected', 'stats', | ||
'home', 'search', 'start', 'terms', 'privacy', 'guidelines', 'profile', 'profile-fork', 'profile-repos', 'profile-activity', 'repository', 'repository-watch', 'directory', 'file', 'markdown', 'markdown-plain', 'diagram', 'image', 'commits', 'commit', | ||
'commit-merged', 'pulls', 'pulls-closed', 'pull', 'pull-open', 'issues', 'issues-closed', 'issue', 'issue-subscribe', 'issue-closed', 'issue-fixed', 'fork', 'empty', 'missing', 'check', 'queue', 'checks', 'pull-queued', 'pull-ejected', 'stats', | ||
'settings', 'settings-branches', 'settings-labels', 'settings-access', 'settings-actions', | ||
'releases', 'releases-image', 'releases-empty', | ||
] | ||
harness/shots/world.mjs+6−0
| @@ -50,6 +50,10 @@ export const viewers = { | ||
'settings-access': 'alice.test', | ||
'settings-actions': 'alice.test', | ||
notifications: 'alice.test', | ||
// One who is neither the owner nor a collaborator: what to hear of the | ||
// repository, and of the issue it opened. | ||
'repository-watch': 'bob.test', | ||
'issue-subscribe': 'bob.test', | ||
} | ||
| ||
const people = { | ||
| @@ -740,6 +744,7 @@ export async function create() { | ||
org: '/textkit.test', | ||
'org-members': '/textkit.test?tab=members', | ||
repository: '/alice.test/tally', | ||
'repository-watch': '/alice.test/tally', | ||
directory: '/alice.test/tally/tree/main/src', | ||
file: '/alice.test/tally/blob/main/src/lib.rs', | ||
blame: '/alice.test/tally/blame/main/src/lib.rs', | ||
| @@ -758,6 +763,7 @@ export async function create() { | ||
issues: '/alice.test/tally/issues', | ||
'issues-closed': '/alice.test/tally/issues?state=closed', | ||
issue: item('issue', utf8), | ||
'issue-subscribe': item('issue', utf8), | ||
'issue-closed': item('issue', typo), | ||
'issue-fixed': item('issue', empty), | ||
fork: '/bob.test/tally', | ||
web/src/lib/molecules/NotificationItem.svelte+25−4
| @@ -2,7 +2,8 @@ | ||
import type * as listNotifications from '#lib/lexicons/dev/knotless/listNotifications.ts'; | ||
import type * as setOrgMembership from '#lib/lexicons/dev/knotless/setOrgMembership.ts'; | ||
import type * as endOrgMembership from '#lib/lexicons/dev/knotless/endOrgMembership.ts'; | ||
import type { DidString } from '@atproto/lex'; | ||
import type * as putSubscription from '#lib/lexicons/dev/knotless/putSubscription.ts'; | ||
import type { AtUriString, DidString } from '@atproto/lex'; | ||
import { call } from '#lib/data.ts'; | ||
import { ago, excerpt, itemHref, repoHref, urlPath } from '#lib/format.ts'; | ||
import { byRunner, failedOf, itemOf, leadOf, markOf, tailOf, type Group } from '#lib/notifications.svelte.ts'; | ||
| @@ -15,7 +16,7 @@ | ||
* Something one or more accounts did that concerns the one looking: a | ||
* mark of what, who, a sentence, the start of what they said, and | ||
* when. An invitation to an organization is answered here, as on its | ||
* Members tab. | ||
* Members tab, and a pull or an issue muted, as on its page. | ||
*/ | ||
let { group }: { group: Group } = $props(); | ||
const n: listNotifications.Notification = $derived(group.first); | ||
| @@ -32,6 +33,24 @@ | ||
let said = $state(''); | ||
let problem = $state(''); | ||
const org = $derived(n.author.did as DidString); | ||
// The pull or issue it may be muted as: not for a review asked, which | ||
// muting leaves heard of. | ||
const thread = $derived(n.reason === 'review-requested' ? undefined : item); | ||
const noun = $derived(thread?.includes('/dev.knotless.pull/') ? 'pull' : 'issue'); | ||
let muted = $state(false); | ||
let muting = $state(false); | ||
async function mute(on: boolean) { | ||
muting = true; | ||
problem = ''; | ||
const level = on ? 'muted' : 'participating'; | ||
try { | ||
await call<typeof putSubscription.main>('dev.knotless.putSubscription', { subject: thread as AtUriString, level }); | ||
muted = on; | ||
} catch (err) { | ||
problem = (err as Error).message; | ||
} | ||
muting = false; | ||
} | ||
async function answer(visibility?: 'public' | 'hidden') { | ||
busy = visibility ?? 'decline'; | ||
problem = ''; | ||
| @@ -57,9 +76,11 @@ | ||
<p><a class="who" href="/{urlPath(n.author.handle)}">{n.author.handle}</a>{#if others === 1}{' and '}<a class="who" href="/{urlPath(group.authors[1].handle)}">{group.authors[1].handle}</a>{:else if others > 1}{` and ${others} others`}{/if}{lead}{#if item && repo}<a href={itemHref(repo, item)}>{n.title ?? 'a thread'}</a>{`${tailOf(n)} on `}{/if}{#if n.repo && n.reason !== 'follow'}<RepoLink owner={n.repo.handle} name={n.repo.name} />{/if}</p> | ||
{#if failed}<p class="failed">{failed}</p>{/if} | ||
{#if n.text && !others}<p class="feed-quote">“{excerpt(n.text, 280)}”</p>{/if} | ||
<p class="below"><Time at={n.createdAt} show={ago} /></p> | ||
{#if muted}<p class="row-said" role="status">You muted this {noun}. <button class="text-button" type="button" disabled={muting} aria-busy={muting} onclick={() => mute(false)}>Undo</button></p>{/if} | ||
{#if problem && n.reason !== 'invite'}<p class="problem" role="alert">{problem}</p>{/if} | ||
<p class="below"><Time at={n.createdAt} show={ago} />{#if thread && !muted}{' · '}<button class="text-button" type="button" disabled={muting} aria-busy={muting} onclick={() => mute(true)}>Mute</button>{/if}</p> | ||
</div> | ||
<span class="mute beside"><Time at={n.createdAt} show={ago} /></span> | ||
<span class="mute beside">{#if thread && !muted}<button class="text-button row-mute" type="button" disabled={muting} aria-busy={muting} onclick={() => mute(true)}>Mute</button>{/if}<Time at={n.createdAt} show={ago} /></span> | ||
{#if n.reason === 'invite'} | ||
<div class="invite-answer"> | ||
{#if said} | ||
web/src/app.css+5−0
| @@ -190,6 +190,11 @@ a.row:hover{background:var(--hover)} | ||
.notifications .invite-answer p{flex-basis:100%} | ||
.notifications li p.invite-said{color:var(--fg)} | ||
.notifications li p.problem{color:var(--danger)} | ||
.notifications li p.row-said{margin-top:var(--space-1);color:var(--fg);font-size:var(--text-sm)} | ||
.notifications li>.beside{display:flex;align-items:baseline;gap:var(--space-3)} | ||
.notifications .row-mute{font-size:var(--text-sm);opacity:0} | ||
.notifications li:hover .row-mute,.notifications .row-mute:focus-visible{opacity:1} | ||
@media (hover:none){.notifications .row-mute{opacity:1}} | ||
.notifications-head .gear{display:flex;margin-left:auto;color:var(--mute)} | ||
.notifications-head .gear:hover{color:var(--fg)} | ||
.notification-kinds{margin:0 0 var(--space-2)} | ||