diff --git a/README.md b/README.md index c835ea7..3dadf3a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,15 @@ substandard shows a badge when the site you're visiting has a standard.site publ detection makes for publications. An unproven handle is replaced by the DID rather than shown. This card replaced the older "by @handle" byline, which showed the claim. See `src/lib/profile.ts`. +- That card says "Following" when the account behind the publication is one + you follow, from the same follow list the subscriber row uses: your own + `app.bsky.graph.follow` records, read from your own repo, so it needs no + extra OAuth scope and no appview sees the question. It is a status and not + a control — this extension can write only its own subscription records, so + following and unfollowing stay on the profile the card links to. An account + you have blocked never gets the line: blocking does not delete the follow + record it supersedes, and a card that is already refusing to show someone + must not announce that you follow them. - The popup shows how many accounts subscribe to the publication, and the faces of the ones you follow. Subscriptions are public records in other people's repos, so the count comes from Constellation, microcosm's free diff --git a/TODO.md b/TODO.md index 58471e5..1b42f72 100644 --- a/TODO.md +++ b/TODO.md @@ -237,13 +237,17 @@ intersecting two lists, and both are capped: says so in its tooltip. - The follow set comes from `listRecords`, which stops at 2000 records. An account following more than that silently loses the tail, and unlike the - scan cap nothing reports it. - -Neither cap binds on any publication measured so far (the largest had 53 -subscribers). Worth revisiting only if a publication gets popular enough that -the row starts lying, and the fix for the second one is probably to ask -Constellation whether *the viewer's* follows subscribe rather than to hold the -whole follow set in the popup. + scan cap nothing reports it beyond the count `src/popup/cards/follows.ts` + logs. + +The first cap binds on a publication and has not bound on any measured so far +(the largest had 53 subscribers). The second binds on the *reader*, and now +costs more than a face: the owner card's "Following" line reads the same set, +so a reader with more than 2000 follows can be told they do not follow an +account they followed early. Both are worth revisiting together, and the fix +for the second is probably to ask Constellation — or the reader's own repo, by +rkey — about the one account in question rather than to hold the whole follow +set in the popup. ## Let the user manage their labelers diff --git a/popup.html b/popup.html index 7c151d7..43c4a9e 100644 --- a/popup.html +++ b/popup.html @@ -62,7 +62,10 @@ - + + + + diff --git a/src/lib/profile.test.ts b/src/lib/profile.test.ts index 173c5ec..e067617 100644 --- a/src/lib/profile.test.ts +++ b/src/lib/profile.test.ts @@ -5,11 +5,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { BLOCKED_NAME, + type Owner, blockedIdentity, fetchOwner, handleMatches, ownerName, ownerSubtitle, + showsFollowing, } from './profile' const OTHER = 'did:plc:impostor00000000000000000' @@ -178,3 +180,32 @@ describe('blockedIdentity', () => { expect(ownerSubtitle(shown)).toBe(full.did) }) }) + +describe('showsFollowing', () => { + const ME = 'did:plc:thereader000000000000000' + const THEM: Owner = { did: 'did:plc:thepublisher00000000000', handle: 'owner.example' } + const viewer = (...dids: string[]) => ({ did: ME, following: new Set(dids) }) + + it('says so when the reader follows the account', () => { + expect(showsFollowing(THEM, false, viewer(THEM.did))).toBe(true) + }) + + it('stays quiet when they do not', () => { + expect(showsFollowing(THEM, false, viewer(OTHER))).toBe(false) + }) + + it('stays quiet with no viewer: signed out, or the follows would not read', () => { + expect(showsFollowing(THEM, false, undefined)).toBe(false) + }) + + it('never draws on a blocked account, follow record or not', () => { + // A block does not delete the follow record it supersedes, so the repo can + // still hold one; the card that is refusing to show them must not. + expect(showsFollowing(THEM, true, viewer(THEM.did))).toBe(false) + }) + + it('says nothing about the reader’s own publication', () => { + const mine = { ...THEM, did: ME } + expect(showsFollowing(mine, false, viewer(ME))).toBe(false) + }) +}) diff --git a/src/lib/profile.ts b/src/lib/profile.ts index d4c11e4..1eaa753 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -16,6 +16,7 @@ // instead of being shown as somebody else's name. import { getRecord, isBlobRef, blobUrl, resolveDid, resolveHandleToDid } from './atproto' +import type { Viewer } from './subscribers' import type { BlobRef } from './types' // `profileAvatarUrl` in atproto.ts reads this same record for the signed-in @@ -116,6 +117,28 @@ export function ownerSubtitle(owner: Owner): string | undefined { return owner.displayName ? owner.did : undefined } +/** + * Whether the card says the reader follows this account. + * + * Three ways the answer is no, and only one of them is "you do not follow + * them": + * + * - A blocked account never gets the line. Blocking somebody does not delete + * the follow record in the blocker's repo — the appview stops honouring it, + * but the record is still there, so reading follows straight off the repo + * can say "Following" about an account the reader has decided not to be + * shown. That reading is the wrong one to publish, and it would sit next + * to a card that is already refusing to show them (see blockedIdentity). + * - No viewer: signed out, or the follow list could not be read. Neither is + * evidence of not following, and the line is only ever drawn as a fact. + * - The reader's own publication. Nobody follows themselves, and the line + * would read as a broken one rather than as an answer. + */ +export function showsFollowing(owner: Owner, blocked: boolean, viewer?: Viewer): boolean { + if (blocked || !viewer || viewer.did === owner.did) return false + return viewer.following.has(owner.did) +} + /** What the card calls an account the reader has blocked. */ export const BLOCKED_NAME = 'Blocked user' diff --git a/src/popup/cards/owner.ts b/src/popup/cards/owner.ts index 7024102..b19175e 100644 --- a/src/popup/cards/owner.ts +++ b/src/popup/cards/owner.ts @@ -13,14 +13,19 @@ import { fetchOwner, ownerName, ownerSubtitle, + showsFollowing, } from '../../lib/profile' +import type { Viewer } from '../../lib/subscribers' import { $, failedIconUrls } from '../dom' -import type { Card } from './card' +import type { Card, CardHost } from './card' +import { resetFollows, viewerFollows } from './follows' /** Kept with the account it describes, so a redraw mid-load draws nothing wrong. */ let owner: { did: string; info: Owner } | undefined /** Account whose lookup already ran this popup open. */ let loadedFor: string | undefined +/** Who the signed-in account follows, for the one question this card asks of it. */ +let follows: Viewer | undefined export const ownerCard: Card = { id: 'owner', @@ -28,15 +33,9 @@ export const ownerCard: Card = { async load(host, refresh) { const did = host.state()?.pub?.did if (!did) return - if (!refresh && loadedFor === did) return - loadedFor = did - const info = await cached( - { scope: 'world', subject: did, name: 'owner' }, - () => fetchOwner(did), - { refresh }, - ) - owner = { did, info } - host.rerender() + // Two unrelated reads — somebody else's profile, and the reader's own + // follows — so neither waits on the other to draw. + await Promise.all([loadProfile(host, did, refresh), loadFollows(host, refresh)]) }, render(host) { @@ -62,8 +61,36 @@ export const ownerCard: Card = { $('owner-handle').hidden = !subtitle $('owner-desc').textContent = info.description ?? '' $('owner-desc').hidden = !info.description + // A follow set read for one account says nothing about the next one, and + // the account can change while the popup is open (Switch account). + const viewer = follows?.did === host.session()?.did ? follows : undefined + $('owner-follows').hidden = !showsFollowing(info, blocked, viewer) renderAvatar(info, blocked) }, + + reset() { + // The publication owner's profile is public and survives a sign-out; the + // follows were the old account's. + follows = undefined + resetFollows() + }, +} + +async function loadProfile(host: CardHost, did: string, refresh: boolean) { + if (!refresh && loadedFor === did) return + loadedFor = did + const info = await cached({ scope: 'world', subject: did, name: 'owner' }, () => fetchOwner(did), { + refresh, + }) + owner = { did, info } + host.rerender() +} + +/** Never throws: viewerFollows answers a failed walk with undefined, and no line. */ +async function loadFollows(host: CardHost, refresh: boolean) { + if (!refresh && follows?.did === host.session()?.did) return + follows = await viewerFollows(host, refresh) + host.rerender() } function renderAvatar(info: Owner, blocked: boolean) { diff --git a/src/popup/popup.css b/src/popup/popup.css index af1510e..ede4ed7 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -236,6 +236,10 @@ actor-typeahead { user-select: none; } .owner-meta { min-width: 0; display: flex; flex-direction: column; } +/* The name, and whatever the reader's own relationship to the account adds to + it. The name takes the space that is left, so a long one ellipsizes rather + than pushing the badge off the card. */ +.owner-line { display: flex; align-items: center; gap: 6px; min-width: 0; } .owner-name { font-weight: 600; font-size: 13px; @@ -243,6 +247,19 @@ actor-typeahead { text-overflow: ellipsis; white-space: nowrap; } +/* Quieter than a label pill: this is the reader's own state, not a claim + anyone made about the account. */ +.owner-follows { + flex: none; + padding: 0 6px; + border: 1px solid var(--border); + border-radius: 999px; + background: color-mix(in srgb, var(--fg) 6%, transparent); + color: var(--muted); + font-size: 10px; + font-weight: 500; + line-height: 15px; +} .owner:hover .owner-name { text-decoration: underline; text-underline-offset: 2px; } .owner-handle { color: var(--muted);