| … | … | @@ -38,7 +38,7 @@ const USAGE = `favela — a p2p feed of markdown records |
| 38 | 38 | favela handles [query] every handle in the namespace, or those matching |
| 39 | 39 | favela resolve <handle> a handle -> feed, and whether the feed AGREES |
| 40 | 40 | favela profile your display name and picture |
| 41 | favela set-profile [--name X] [--bio "..."] [--avatar <file>] | |
| 41 | favela set-profile [--name X] [--bio "..."] [--avatar <file>] [--banner <file>] | |
| 42 | 42 | favela invite [--ttl <ms>] one-time invite for a browser |
| 43 | 43 | favela browsers browsers you have admitted |
| 44 | 44 | favela revoke <publicKey> retire a browser |
| … | … | @@ -266,14 +266,17 @@ async function main () { |
| 266 | 266 | case 'set-profile': { |
| 267 | 267 | const name = str(argv, 'name') |
| 268 | 268 | const avatar = str(argv, 'avatar') |
| 269 | // A banner is the wide header image; --avatar is the round one. Both are optional and | |
| 270 | // independent, and passing neither with --name/--bio leaves both alone. | |
| 271 | const banner = str(argv, 'banner') | |
| 269 | 272 | // ⛔ THE BIO HAD NO WAY IN. `peer.setProfile` has taken `{name, avatar, bio}` since it was |
| 270 | 273 | // written and this caller only ever passed the first two, so the supported surface could not |
| 271 | 274 | // set a bio at all — the profile record's body stayed empty on every node, and every |
| 272 | 275 | // projection of it downstream (the AP actor's `summary`, the surface's byline) was empty for |
| 273 | 276 | // a reason that looked like the projection was broken. It was never reached. |
| 274 | 277 | const bio = str(argv, 'bio') |
| 275 | if (name === undefined && avatar === undefined && bio === undefined) { | |
| 276 | throw new Error('set-profile needs --name, --bio and/or --avatar <file>') | |
| 278 | if (name === undefined && avatar === undefined && bio === undefined && banner === undefined) { | |
| 279 | throw new Error('set-profile needs --name, --bio, --avatar <file> and/or --banner <file>') | |
| 277 | 280 | } |
| 278 | 281 | // One call, so a name-and-bio change is ONE record write and not two — the record is |
| 279 | 282 | // overwritten in place, and two writes would briefly publish a profile with only half of it. |
| … | … | @@ -294,6 +297,11 @@ async function main () { |
| 294 | 297 | const r = await call('avatar-put', { path: avatar }) |
| 295 | 298 | console.error('avatar ' + r.mimeType + ' ' + Math.round(r.size / 1024) + ' KiB -> ' + r.oid.slice(0, 16) + '…') |
| 296 | 299 | } |
| 300 | if (banner !== undefined) { | |
| 301 | if (!banner) throw new Error('--banner needs a file path') | |
| 302 | const r = await call('banner-put', { path: banner }) | |
| 303 | console.error('banner ' + r.mimeType + ' ' + Math.round(r.size / 1024) + ' KiB -> ' + r.oid.slice(0, 16) + '…') | |
| 304 | } | |
| 297 | 305 | return console.log(JSON.stringify(await call('profile'), null, 2)) |
| 298 | 306 | } |
| 299 | 307 | case 'invite': { |
| … | … | @@ -308,7 +308,7 @@ export class Peer { |
| 308 | 308 | return this.reach() |
| 309 | 309 | } |
| 310 | 310 | |
| 311 | async setProfile ({ name, avatar, bio } = {}) { | |
| 311 | async setProfile ({ name, avatar, bio, banner } = {}) { | |
| 312 | 312 | await this.feed.update() |
| 313 | 313 | const current = await this.feed.get(PROFILE, SELF) |
| 314 | 314 | const fm = { ...((current && current.frontmatter) || {}) } |
| … | … | @@ -326,6 +326,14 @@ export class Peer { |
| 326 | 326 | if (avatar) fm.avatar = String(avatar) |
| 327 | 327 | else delete fm.avatar |
| 328 | 328 | } |
| 329 | // ⛔ A SECOND PICTURE, not a variant of the first. AS2 has carried both `icon` and `image` | |
| 330 | // since forever and Mastodon renders both, so an identity that can only hold one is missing a | |
| 331 | // field the destination already had (hyper-social #67 §7.6). Same shape as the avatar — an oid, | |
| 332 | // bytes in the overlay — because nothing about a banner needs a second mechanism. | |
| 333 | if (banner !== undefined) { | |
| 334 | if (banner) fm.banner = String(banner) | |
| 335 | else delete fm.banner | |
| 336 | } | |
| 329 | 337 | // The record is mutable and overwritten in place, so createdAt keeps meaning creation. |
| 330 | 338 | if (typeof fm.createdAt !== 'string') fm.createdAt = new Date().toISOString() |
| 331 | 339 | |
| … | … | @@ -349,12 +357,12 @@ export class Peer { |
| 349 | 357 | } |
| 350 | 358 | |
| 351 | 359 | await this.feed.put(PROFILE, SELF, { frontmatter: fm, body }) |
| 352 | return { name: fm.name || null, avatar: fm.avatar || null, bio: body.trim() || null } | |
| 360 | return { name: fm.name || null, avatar: fm.avatar || null, banner: fm.banner || null, bio: body.trim() || null } | |
| 353 | 361 | } |
| 354 | 362 | |
| 355 | 363 | /** This feed's own profile. */ |
| 356 | 364 | async profile () { |
| 357 | return (await this.profileOf(this.feed.key)) || { name: null, avatar: null, bio: null } | |
| 365 | return (await this.profileOf(this.feed.key)) || { name: null, avatar: null, banner: null, bio: null } | |
| 358 | 366 | } |
| 359 | 367 | |
| 360 | 368 | /** |
| … | … | @@ -371,7 +379,7 @@ export class Peer { |
| 371 | 379 | const rec = await feed.get(PROFILE, SELF) |
| 372 | 380 | if (!rec) return null |
| 373 | 381 | const fm = rec.frontmatter || {} |
| 374 | return { name: fm.name || null, avatar: fm.avatar || null, bio: (rec.body || '').trim() || null } | |
| 382 | return { name: fm.name || null, avatar: fm.avatar || null, banner: fm.banner || null, bio: (rec.body || '').trim() || null } | |
| 375 | 383 | } |
| 376 | 384 | return null |
| 377 | 385 | } |
| … | … | @@ -401,6 +409,7 @@ export class Peer { |
| 401 | 409 | out[author] = { |
| 402 | 410 | name: fm.name || null, |
| 403 | 411 | avatar: fm.avatar || null, |
| 412 | banner: fm.banner || null, | |
| 404 | 413 | bio: (rec.body || '').trim() || null, |
| 405 | 414 | root: (founder && founder.identityRoot) || null |
| 406 | 415 | } |
| … | … | @@ -336,6 +336,16 @@ export async function dispatch (node, cmd, a) { |
| 336 | 336 | await peer.update() |
| 337 | 337 | return r |
| 338 | 338 | } |
| 339 | // ⛔ A SECOND PICTURE, stored by the SAME machinery. `avatars.put` types from magic bytes and | |
| 340 | // registers in the overlay — none of which is avatar-specific — so a banner needs a verb, not a | |
| 341 | // mechanism. It is a separate verb rather than `avatar-put {field}` because the two are | |
| 342 | // different acts and a mistyped field name would silently replace the wrong picture. | |
| 343 | case 'banner-put': { | |
| 344 | const r = await node.avatars.put(a.path) | |
| 345 | await peer.setProfile({ banner: r.oid }) | |
| 346 | await peer.update() | |
| 347 | return r | |
| 348 | } | |
| 339 | 349 | case 'avatar-get': { |
| 340 | 350 | const r = await node.avatars.get(a.oid) |
| 341 | 351 | return r || { found: false } |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.48", | |
| 4 | "description": "The favela daemon — feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", | |
| 3 | "version": "0.0.49", | |
| 4 | "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| 7 | 7 | "test": "brittle-bare test/*.test.js" |
| … | … | @@ -43,7 +43,7 @@ async function mkPeer () { |
| 43 | 43 | test('a fresh feed publishes no profile at all', async (t) => { |
| 44 | 44 | const b = await mkPeer() |
| 45 | 45 | t.teardown(() => b.close()) |
| 46 | t.alike(await b.profile(), { name: null, avatar: null, bio: null }, 'nothing published yet') | |
| 46 | t.alike(await b.profile(), { name: null, avatar: null, banner: null, bio: null }, 'nothing published yet') | |
| 47 | 47 | t.alike(await b.profiles(), {}, |
| 48 | 48 | 'and ABSENT from the map — "has not published one" and "published an empty one" differ') |
| 49 | 49 | }) |
| … | … | @@ -286,3 +286,36 @@ test('★ profiles carry the identity ROOT — what a mention has to name', asyn |
| 286 | 286 | t.is(row.root, founder.identityRoot, 'the root the feed was founded under') |
| 287 | 287 | t.is(row.root.length, 64) |
| 288 | 288 | }) |
| 289 | ||
| 290 | // ── the banner ──────────────────────────────────────────────────────────────────────────────── | |
| 291 | // | |
| 292 | // ⛔ A BANNER IS A SECOND PICTURE, not a variant of the first. AS2 gives an actor `icon` (the | |
| 293 | // avatar) and `image` (the header), and Mastodon renders both — so a profile that can only carry | |
| 294 | // one of them is missing a field the destination has always had. `getProfile` could not emit | |
| 295 | // `image` because nothing upstream ever stored one: hyper-social #67 §7.6, "the banner cannot work | |
| 296 | // at all — it needs a new field end to end". | |
| 297 | // | |
| 298 | // It reuses the avatar's machinery exactly: an oid on the profile record, bytes in the overlay, | |
| 299 | // typed from magic bytes. Nothing about a banner needs a second mechanism. | |
| 300 | test('a banner is stored alongside the avatar and neither displaces the other', async (t) => { | |
| 301 | const p = await mkPeer() | |
| 302 | ||
| 303 | await p.setProfile({ name: 'ash', avatar: 'a'.repeat(64) }) | |
| 304 | const withBanner = await p.setProfile({ banner: 'b'.repeat(64) }) | |
| 305 | ||
| 306 | t.is(withBanner.avatar, 'a'.repeat(64), 'setting a banner leaves the avatar alone') | |
| 307 | t.is(withBanner.banner, 'b'.repeat(64), 'and the banner is returned') | |
| 308 | t.is(withBanner.name, 'ash', 'and the name survives both') | |
| 309 | ||
| 310 | // ⚠ Blank REMOVES, matching how name/avatar/bio already behave. Absence asserts nothing. | |
| 311 | const cleared = await p.setProfile({ banner: '' }) | |
| 312 | t.absent(cleared.banner, 'a blank banner removes it') | |
| 313 | t.is(cleared.avatar, 'a'.repeat(64), 'and still does not touch the avatar') | |
| 314 | }) | |
| 315 | ||
| 316 | test('the banner travels in the published record, not a local registry', async (t) => { | |
| 317 | const p = await mkPeer() | |
| 318 | await p.setProfile({ banner: 'c'.repeat(64) }) | |
| 319 | const rec = await p.feed.get('profile', 'self') | |
| 320 | t.is(rec.frontmatter.banner, 'c'.repeat(64), '⛔ in the FEED — a registry does not replicate') | |
| 321 | }) |