chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Bots wear a robot after their name, and on their avatar in a stack
crates/knotless-serve/src/web/actor.rs+6−1
| @@ -11,15 +11,20 @@ pub struct ActorView { | ||
pub handle: String, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
pub avatar: Option<String>, | ||
#[serde(skip_serializing_if = "std::ops::Not::not")] | ||
pub bot: bool, | ||
} | ||
| ||
/// `did`, with its handle and its picture's address in full. | ||
/// `did`, with its handle, its picture's address in full, and whether the | ||
/// index has it saying it is a bot. | ||
pub async fn actor(app: &App, did: &str) -> ActorView { | ||
let avatar = avatar(app, did).await; | ||
let bot = app.index.as_ref().and_then(|i| i.is_bot(did).ok()); | ||
ActorView { | ||
did: did.to_string(), | ||
handle: handle(app, did).await, | ||
avatar: avatar.map(absolute), | ||
bot: bot.unwrap_or(false), | ||
} | ||
} | ||
| ||
crates/knotless-serve/src/index.rs+9−0
| @@ -1954,6 +1954,13 @@ impl Index { | ||
rows?.collect() | ||
} | ||
| ||
/// Whether `did`'s knotless profile says it is a bot. | ||
pub fn is_bot(&self, did: &str) -> rusqlite::Result<bool> { | ||
let db = self.readers.take(&self.db); | ||
let mut stmt = db.prepare_cached("SELECT 1 FROM bot WHERE did = ?1")?; | ||
stmt.exists([did]) | ||
} | ||
| ||
/// `viewer`'s follow naming `did`, by its URI, if they follow it. | ||
pub fn follow_of(&self, viewer: &str, did: &str) -> rusqlite::Result<Option<String>> { | ||
let db = self.readers.take(&self.db); | ||
| @@ -6319,7 +6326,9 @@ mod tests { | ||
.map(|p| p.by) | ||
.collect(); | ||
assert_eq!(by, ["did:a", "did:a"]); | ||
assert!(index.is_bot("did:b").unwrap() && !index.is_bot("did:a").unwrap()); | ||
index.delete("did:b", nsid::PROFILE, "self").unwrap(); | ||
assert!(!index.is_bot("did:b").unwrap()); | ||
let of = |did| { | ||
index | ||
.pushes(10, None, Some(did), None, false) | ||
docs/reference/api.md+7−2
| @@ -135,6 +135,11 @@ with `-`, or one holding a NUL, newline or space is 400 `bad revision | ||
XRPC queries under `/xrpc/`. All but `getProfile`, `describeServer` and `getStats` need `--index`, and | ||
without one answer 404 `this server keeps no index`. | ||
| ||
An account shown beside what it did or holds (`dev.knotless.defs#actorView`: | ||
an owner, an author, a collaborator, a reviewer) is `{did, handle, avatar?, | ||
bot?}`, where `bot` is true when its `dev.knotless.profile` says it is a bot, | ||
as the index keeps of every account. | ||
| ||
### `dev.knotless.listRepos` | ||
| ||
| Parameter | Default | | | ||
| @@ -186,7 +191,7 @@ Zoekt cannot read answers 400, and a server without `--code-index` 501. | ||
| `q` | | required; matched against the handle, ignoring case | | ||
| `limit` | 10 | clamped to 1 to 50 | | ||
| ||
Answers `owners`, each an `owner` (`did`, `handle`, `avatar?`) and its | ||
Answers `owners`, each an `owner` (`did`, `handle`, `avatar?`, `bot?`) and its | ||
`repoCount`: the accounts owning repositories `listRepos` lists, those whose | ||
handle starts with `q` first, then the one with the newest repository. Owners | ||
are matched by the handles the index keeps; an owner whose handle was never | ||
| @@ -241,7 +246,7 @@ names now count. | ||
| `limit` | 40 | clamped to 1 to 100 | | ||
| `cursor` | | from the page before | | ||
| ||
Answers `events` (`createdAt`, `actor` `{did, handle, avatar?}`, `repo` | ||
Answers `events` (`createdAt`, `actor` `{did, handle, avatar?, bot?}`, `repo` | ||
`{did, handle, name}`, `action`) and `cursor`. `action.$type` is one of: | ||
| ||
| `$type` | Fields | | ||
lexicons/dev/knotless/defs.json+2−1
| @@ -64,7 +64,8 @@ | ||
"properties": { | ||
"did": { "type": "string", "format": "did" }, | ||
"handle": { "type": "string", "description": "The handle the account claims, when it resolves back to it; else the DID." }, | ||
"avatar": { "type": "string", "format": "uri", "description": "The account's picture, as in dev.knotless.getProfile#profileView." } | ||
"avatar": { "type": "string", "format": "uri", "description": "The account's picture, as in dev.knotless.getProfile#profileView." }, | ||
"bot": { "type": "boolean", "description": "Whether its knotless profile says it is a bot, as the appview has indexed. Absent when it does not, and from an appview that keeps no index." } | ||
} | ||
}, | ||
"themeView": { | ||
lexicons/dev/knotless/getFeed.json+2−1
| @@ -48,7 +48,8 @@ | ||
"properties": { | ||
"did": { "type": "string", "format": "did" }, | ||
"handle": { "type": "string", "description": "The handle the account claims, when it resolves back to it; else the DID." }, | ||
"avatar": { "type": "string", "format": "uri", "description": "The account's picture, as in dev.knotless.getProfile#profileView." } | ||
"avatar": { "type": "string", "format": "uri", "description": "The account's picture, as in dev.knotless.getProfile#profileView." }, | ||
"bot": { "type": "boolean", "description": "Whether its knotless profile says it is a bot, as in dev.knotless.defs#actorView." } | ||
} | ||
}, | ||
"repoView": { | ||
web/src/lib/atoms/Avatar.svelte+30−11
| @@ -7,33 +7,52 @@ | ||
| ||
<script lang="ts"> | ||
import { hue, identicon } from '#lib/format.ts'; | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
/** | ||
* A round mark: the picture at `src`, or else a pattern in a color that | ||
* `did` picks, the account's own wherever it shows, or `name` for one | ||
* with no account. | ||
* with no account. An account that says it is a bot (`bot`) wears a | ||
* robot on its corner, the one `.av` holding the picture and the robot. | ||
*/ | ||
let { | ||
name, | ||
did, | ||
size, | ||
src | ||
}: { name: string; did?: string; size?: 'lg' | 'xl'; src?: string | null } = $props(); | ||
src, | ||
bot | ||
}: { | ||
name: string; | ||
did?: string; | ||
size?: 'lg' | 'xl'; | ||
src?: string | null; | ||
bot?: boolean; | ||
} = $props(); | ||
let cls = $derived(size ? `av ${size}` : 'av'); | ||
const seed = $derived(did ?? name); | ||
/** The thumbnail wherever it is drawn small: a twentieth of the bytes. */ | ||
const shown = $derived(size === 'xl' ? src : src?.replace(BLUESKY_AVATAR, THUMBNAIL)); | ||
</script> | ||
| ||
{#if shown}<img class={cls} src={shown} alt="" />{:else}<svg | ||
{#snippet pattern()}{#each identicon(seed) as [x, y] (`${x},${y}`)}<rect | ||
{x} | ||
{y} | ||
width="1" | ||
height="1" | ||
/>{/each}{/snippet} | ||
| ||
{#if bot}<span | ||
class="{cls} bot{shown ? '' : ' id'}" | ||
style={shown ? undefined : `--h:${hue(seed)}`} | ||
title="Bot" | ||
>{#if shown}<img src={shown} alt="" />{:else}<svg | ||
viewBox="-1 -1 7 7" | ||
shape-rendering="crispEdges" | ||
aria-hidden="true">{@render pattern()}</svg | ||
>{/if}<span class="av-badge"><Icon name="bot" /></span></span | ||
>{:else if shown}<img class={cls} src={shown} alt="" />{:else}<svg | ||
class="{cls} id" | ||
style="--h:{hue(seed)}" | ||
viewBox="-1 -1 7 7" | ||
shape-rendering="crispEdges" | ||
aria-hidden="true" | ||
>{#each identicon(seed) as [x, y] (`${x},${y}`)}<rect | ||
{x} | ||
{y} | ||
width="1" | ||
height="1" | ||
/>{/each}</svg | ||
aria-hidden="true">{@render pattern()}</svg | ||
>{/if} | ||
web/src/lib/atoms/BotMark.sveltenew+6−0
| @@ -0,0 +1,6 @@ | ||
<script lang="ts"> | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
/** The robot after the name of an account that says it is a bot. */ | ||
</script> | ||
| ||
<span class="bot-mark" role="img" aria-label="Bot" title="Bot"><Icon name="bot" /></span> | ||
web/src/lib/molecules/AssigneesFact.svelte+5−2
| @@ -1,6 +1,7 @@ | ||
<script lang="ts"> | ||
import type { ActorView } from '#lib/lexicons/dev/knotless/defs.ts'; | ||
import Avatar from '#lib/atoms/Avatar.svelte'; | ||
import BotMark from '#lib/atoms/BotMark.svelte'; | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
/** | ||
* Whom a pull or an issue is assigned to, as a fact of it: their faces | ||
| @@ -39,7 +40,7 @@ | ||
<summary aria-label="Assigned to {assignees.map((a) => a.handle).join(', ')}"> | ||
<span class="avs"> | ||
{#each assignees.slice(0, SHOWN).reverse() as a (a.did)}<span class="face" | ||
><Avatar name={a.handle} did={a.did} src={a.avatar} /></span | ||
><Avatar name={a.handle} did={a.did} src={a.avatar} bot={a.bot} /></span | ||
>{/each} | ||
</span> | ||
<span class="said">Assigned to <b>{who}</b></span> | ||
| @@ -49,7 +50,9 @@ | ||
{#each assignees as a (a.did)} | ||
<div class="menu-item reviewer"> | ||
<Avatar name={a.handle} did={a.did} src={a.avatar} /> | ||
<span class="who">{a.handle}</span> | ||
<span class="who" | ||
>{a.handle}{#if a.bot}<BotMark />{/if}</span | ||
> | ||
{#if assign}<button | ||
class="stop" | ||
type="button" | ||
web/src/lib/molecules/Crowd.svelte+5−2
| @@ -2,9 +2,11 @@ | ||
import type * as getFeed from '#lib/lexicons/dev/knotless/getFeed.ts'; | ||
import { urlPath } from '#lib/format.ts'; | ||
import Avatar from '#lib/atoms/Avatar.svelte'; | ||
import BotMark from '#lib/atoms/BotMark.svelte'; | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
/** Several accounts who did one thing: a row of their pictures, the first few, opening to all of them by name. */ | ||
let { actors }: { actors: Pick<getFeed.ActorView, 'did' | 'handle' | 'avatar'>[] } = $props(); | ||
let { actors }: { actors: Pick<getFeed.ActorView, 'did' | 'handle' | 'avatar' | 'bot'>[] } = | ||
$props(); | ||
const SHOWN = 8; | ||
const more = $derived(actors.length - SHOWN); | ||
</script> | ||
| @@ -16,6 +18,7 @@ | ||
name={a.handle} | ||
did={a.did} | ||
src={a.avatar} | ||
bot={a.bot} | ||
/>{/each}</span | ||
>{#if more > 0}<span class="more">+{more}</span>{/if}<Icon name="caret" /></summary | ||
> | ||
| @@ -23,7 +26,7 @@ | ||
{#each actors as a (a.did)}<li> | ||
<Avatar name={a.handle} did={a.did} src={a.avatar} /><a href="/{urlPath(a.handle)}" | ||
>{a.handle}</a | ||
> | ||
>{#if a.bot}<BotMark />{/if} | ||
</li>{/each} | ||
</ul> | ||
</details> | ||
web/src/lib/molecules/FeedItem.svelte+2−1
| @@ -17,6 +17,7 @@ | ||
urlPath | ||
} from '#lib/format.ts'; | ||
import Avatar from '#lib/atoms/Avatar.svelte'; | ||
import BotMark from '#lib/atoms/BotMark.svelte'; | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
import Ref from '#lib/atoms/Ref.svelte'; | ||
import Time from '#lib/atoms/Time.svelte'; | ||
| @@ -109,7 +110,7 @@ | ||
| ||
{#snippet where()}{' on '}<RepoLink owner={x.repo.handle} name={x.repo.name} />{/snippet} | ||
{#snippet who()}{#if !own}<a class="who" href="/{urlPath(x.actor.handle)}">{x.actor.handle}</a | ||
>{/if}{own ? '' : ' '}{/snippet} | ||
>{#if x.actor.bot}<BotMark />{/if}{/if}{own ? '' : ' '}{/snippet} | ||
{#snippet rest( | ||
b: { count: number; merges: number; elsewhere: number; ref: string; to?: string }, | ||
listed: number | ||