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

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Releases keep their start across levels, and a notification mutes its thread

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/subscription-fixes
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)}