diff --git a/main.js b/main.js index 1524505..d002ad9 100644 --- a/main.js +++ b/main.js @@ -176,28 +176,6 @@ var PluginData = class { getRecord(repo, collection, rkey) { return hostCall("getRecord", { repo, collection, rkey }); } - // Writes to the signed-in user's own repo only — there is no `repo` - // parameter, unlike getRecord, since a plugin can never write to anyone - // else's account. There's also no `collection` parameter: every - // records-write plugin shares one fixed collection (requires - // `permissions.records: ["write"]`, granted by the user at install time), - // and the host rejects any rkey that already belongs to a different - // plugin, so a plugin only ever needs to pick its own rkey. - putRecord(rkey, record) { - return hostCall("putRecord", { rkey, record }); - } - deleteRecord(rkey) { - return hostCall("deleteRecord", { rkey }); - } - // Lists this plugin's own records in the shared collection — never - // another plugin's, even though they live in the same collection; the - // host filters by ownership before this ever reaches plugin code. - // Requires permissions.records: ["write"], same as putRecord/deleteRecord. - // Returns { cursor, records: [{uri, cid, value}] } — page through with - // cursor until it comes back undefined/null. - listRecords(cursor, limit) { - return hostCall("listRecords", { cursor, limit }); - } }; var App = class { constructor() { @@ -236,33 +214,6 @@ var App = class { return hostCall("showMoreLikeThis", { postUri, feedUri }); } }; -var Notice = class { - constructor(message, timeout = 0) { - this._toastId = uuid.create(); - this._timeout = timeout; - this._hidden = false; - this.noticeEl = new VirtualEl("div"); - this.noticeEl.addClass("toast"); - this.noticeEl.setText(message); - queueMicrotask(() => { - if (this._hidden) return; - hostCall("showToast", { - toastId: this._toastId, - element: this.noticeEl._serialize(), - timeout: this._timeout - }); - }); - } - setMessage(message) { - this.noticeEl.setText(message); - return this; - } - hide() { - if (this._hidden) return; - this._hidden = true; - hostCall("hideToast", { toastId: this._toastId }); - } -}; var StyleSnippet = class { constructor(cssText) { this._snippetId = uuid.create(); @@ -390,10 +341,9 @@ var Plugin = class { handlerId }); } - // Forces every mounted to re-invoke its registered - // plugins' callbacks (including other plugins' — the slot has no concept - // of per-plugin refresh). Useful when a slot's content depends on data - // that resolves asynchronously after the initial render. + // Makes every mounted re-invoke this plugin's + // registered callbacks for that slot. Useful when a slot's content depends + // on data that resolves asynchronously after the initial render. refreshSlot(name) { return hostCall("refreshSlot", { name }); } @@ -449,6 +399,20 @@ var Modal = class { ] }); } + update() { + if (!openModals.has(this._modalId)) return; + self.postMessage({ + type: "hostCall", + method: "updateModal", + args: [ + { + modalId: this._modalId, + title: this.titleEl._serialize(), + content: this.contentEl._serialize() + } + ] + }); + } close() { if (!openModals.has(this._modalId)) return; openModals.delete(this._modalId); @@ -857,60 +821,10 @@ self.onmessage = async (event) => { }; // src/main.js -var SHARED_COLLECTION = "social.impro.plugins.cloaca"; var DEFAULT_DATA = { - // { id, label, secret }[] — never synced to the PDS or account - // preferences, only ever stored via loadLocalData/saveLocalData (see - // pluginLocalDataStore.js on the host side). - keys: [], - // Which saved key's tags to render as badges. Exactly one at a time, or - // null to show nothing. - displayKeyId: null, - // Local index of every one of this plugin's own records, keyed by rkey - // (not by key id or did — rkeys from different keys/dids never collide - // in practice, since each is its own HMAC output, so one flat map covers - // every saved key at once). This is what makes badge rendering a pure - // local lookup instead of a network round trip per post: it's rebuilt - // wholesale from a paged listRecords call once at startup (see - // syncFromPds), then kept current incrementally by saveTags/removeTags/ - // fetchTags. It's a local index only — the PDS has no way to list "every - // DID I've tagged" itself, since a record's rkey reveals nothing about - // the DID it's for — so this can drift if tags are edited from another - // device/app between syncs; the PDS record is always the source of - // truth. - rkeyIndex: {}, - // The reverse of rkeyIndex — { [keyId]: { [did]: { tags, updatedAt } } } - // — used to answer "who's tagged X". Unlike rkeyIndex, this can only ever - // be built for DIDs this plugin has actually resolved on this device (by - // rendering a badge for them under their key, or via the edit modal) — - // there's no way to recover a DID from a record's rkey/value, by design, - // so a bulk PDS sync can never populate this. "Everyone tagged X" is - // therefore best-effort: complete for accounts you've encountered here, - // not necessarily complete for ones tagged from another device that - // haven't been seen on this one since. - didIndex: {} + // { [did]: { tags: string[], updatedAt: string } } + tags: {} }; -function toHex(bytes) { - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); -} -function randomSecret() { - return toHex(crypto.getRandomValues(new Uint8Array(24))); -} -function newKeyId() { - return toHex(crypto.getRandomValues(new Uint8Array(8))); -} -async function hmacSha256Hex(secret, message) { - const bytes = new TextEncoder().encode(message); - const cryptoKey = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const signature = await crypto.subtle.sign("HMAC", cryptoKey, bytes); - return toHex(new Uint8Array(signature)); -} function parseTagsInput(text) { const seen = /* @__PURE__ */ new Set(); for (const raw of text.split(",")) { @@ -929,30 +843,16 @@ async function describeProfile(app, did) { } return did; } -var EditTagsModal = class _EditTagsModal extends Modal { - constructor(plugin, did, keyId, tags) { +var EditTagsModal = class extends Modal { + constructor(plugin, did, tags) { super(); this.plugin = plugin; this.did = did; - this.keyId = keyId; this.tags = tags; this._tagsText = tags.join(", "); } onOpen() { this.titleEl.setText("Edit tags"); - const keys = this.plugin.getKeys(); - new Setting(this.contentEl).setName("Key").setDesc("Which local key this tag is stored under").addDropdown((dropdown) => { - dropdown.addOptions( - Object.fromEntries(keys.map((key) => [key.id, key.label])) - ); - dropdown.setValue(this.keyId); - dropdown.onChange(async (value) => { - if (value === this.keyId) return; - this.close(); - const { tags } = await this.plugin.fetchTags(value, this.did); - new _EditTagsModal(this.plugin, this.did, value, tags).open(); - }); - }); new Setting(this.contentEl).setName("Tags").setDesc("Comma-separated").addText( (text) => text.setPlaceholder("friend, met-at-conf").setValue(this._tagsText).onChange((value) => { this._tagsText = value; @@ -964,7 +864,7 @@ var EditTagsModal = class _EditTagsModal extends Modal { cls: "modal-dialog-button primary-button" }).onClick(async () => { const tags = parseTagsInput(this._tagsText); - await this.plugin.saveTags(this.keyId, this.did, tags); + await this.plugin.saveTags(this.did, tags); this.close(); }); if (this.tags.length > 0) { @@ -972,7 +872,7 @@ var EditTagsModal = class _EditTagsModal extends Modal { text: "Remove", cls: "modal-dialog-button danger-button" }).onClick(async () => { - await this.plugin.removeTags(this.keyId, this.did); + await this.plugin.removeTags(this.did); this.close(); }); } @@ -984,15 +884,22 @@ var EditTagsModal = class _EditTagsModal extends Modal { } }; var TagListModal = class extends Modal { - constructor(plugin, keyId, tag, entries) { + constructor(plugin, tag, entries) { super(); this.plugin = plugin; - this.keyId = keyId; this.tag = tag; this.entries = entries; } onOpen() { this.titleEl.setText(`Tagged "${this.tag}"`); + this.render(); + } + // Rebuilds contentEl from this.entries. Called once from onOpen, and + // again (followed by this.update()) whenever a tag is removed from + // within the list, so the modal refreshes in place instead of closing + // and reopening. + render() { + this.contentEl.empty(); if (this.entries.length === 0) { this.contentEl.createEl("p", { text: "No accounts found on this device for this tag.", @@ -1004,7 +911,7 @@ var TagListModal = class extends Modal { row.createEl("span", { text: label, cls: "tag-list-row-name" }); row.createEl("button", { text: "Edit", cls: "modal-dialog-button" }).onClick(() => { this.close(); - new EditTagsModal(this.plugin, did, this.keyId, tags).open(); + new EditTagsModal(this.plugin, did, tags).open(); }); row.createEl("button", { text: `Remove "${this.tag}"`, @@ -1012,12 +919,13 @@ var TagListModal = class extends Modal { }).onClick(async () => { const updated = tags.filter((t) => t !== this.tag); if (updated.length === 0) { - await this.plugin.removeTags(this.keyId, did); + await this.plugin.removeTags(did); } else { - await this.plugin.saveTags(this.keyId, did, updated); + await this.plugin.saveTags(did, updated); } - this.close(); - this.plugin.openTagListModal(this.keyId, this.tag); + this.entries = await this.plugin.fetchTagListEntries(this.tag); + this.render(); + this.update(); }); } const buttons = this.contentEl.createDiv({ cls: "modal-dialog-buttons" }); @@ -1032,108 +940,35 @@ var TagsSettingTab = class extends PluginSettingTab { constructor() { super(); this.setName("Tags"); - this._newLabel = ""; - this._newSecret = ""; - this._browseKeyId = null; this._browseTag = null; } display() { - const keys = this.plugin.getKeys(); - const displayKeyId = this.plugin.getDisplayKeyId(); - new Setting(this.containerEl).setName("Display key").setDesc( - "Which key's tags to show as badges next to accounts. Only tags saved under this key are visible to you here \u2014 nothing about the other keys is revealed." - ).addDropdown((dropdown) => { - dropdown.addOptions({ - "": "None", - ...Object.fromEntries(keys.map((key) => [key.id, key.label])) - }); - dropdown.setValue(displayKeyId ?? ""); - dropdown.onChange(async (value) => { - await this.plugin.setDisplayKey(value || null); - }); - }); - this.containerEl.createEl("h2", { - text: "Keys", - cls: "setting-item-name" + const tags = this.plugin.getAllTags(); + this.containerEl.createEl("p", { + text: "Tag accounts from the post/profile menu \u2014 tags are private to you and synced across your devices.", + cls: "setting-item-desc" }); - if (keys.length === 0) { + if (tags.length === 0) { this.containerEl.createEl("p", { - text: "No keys yet. Add one below to start tagging accounts.", + text: "No tags yet. Tag an account from its profile or post menu to get started.", cls: "setting-item-desc" }); + return; } - for (const key of keys) { - new Setting(this.containerEl).setName(key.label).setDesc(key.id === displayKeyId ? "Current display key" : "").addButton( - (button) => button.setButtonText("Delete").onClick(async () => { - await this.plugin.removeKey(key.id); - this.refresh({ reset: true }); - }) - ); - } - new Setting(this.containerEl).setName("New key label").setDesc("A name to help you tell your keys apart").addText( - (text) => text.setPlaceholder("personal").onChange((value) => { - this._newLabel = value; - }) - ); - new Setting(this.containerEl).setName("New key secret").setDesc( - "Leave blank to generate a new random one, or paste an existing key to share tags with another device or app that uses the same scheme." - ).addText( - (text) => text.setPlaceholder("leave blank to generate").onChange((value) => { - this._newSecret = value; - }) - ); - new Setting(this.containerEl).addButton( - (button) => button.setButtonText("Add key").setCta().onClick(async () => { - const label = this._newLabel.trim() || "Untitled key"; - const secret = this._newSecret.trim() || randomSecret(); - this._newLabel = ""; - this._newSecret = ""; - await this.plugin.addKey(label, secret); - this.refresh({ reset: true }); - }) - ); - if (keys.length === 0) return; this.containerEl.createEl("h2", { text: "Browse by tag", cls: "setting-item-name" }); - this.containerEl.createEl("p", { - text: "Shows accounts tagged on this device, under any saved key \u2014 not necessarily every account ever tagged under that key (see the README).", - cls: "setting-item-desc" - }); - const browseKeyId = this._browseKeyId ?? displayKeyId ?? keys[0].id; - new Setting(this.containerEl).setName("Key").addDropdown((dropdown) => { - dropdown.addOptions( - Object.fromEntries(keys.map((key) => [key.id, key.label])) - ); - dropdown.setValue(browseKeyId); - dropdown.onChange((value) => { - this._browseKeyId = value; - this._browseTag = null; - this.refresh({ reset: true }); - }); - }); - const tagsForKey = this.plugin.getAllTagsForKey(browseKeyId); - if (tagsForKey.length === 0) { - this.containerEl.createEl("p", { - text: "No tags seen yet for this key on this device.", - cls: "setting-item-desc" - }); - return; - } - const browseTag = this._browseTag ?? tagsForKey[0]; + const browseTag = this._browseTag ?? tags[0]; new Setting(this.containerEl).setName("Tag").addDropdown((dropdown) => { - dropdown.addOptions(Object.fromEntries(tagsForKey.map((t) => [t, t]))); + dropdown.addOptions(Object.fromEntries(tags.map((t) => [t, t]))); dropdown.setValue(browseTag); dropdown.onChange((value) => { this._browseTag = value; }); }).addButton( (button) => button.setButtonText("View").onClick(() => { - this.plugin.openTagListModal( - browseKeyId, - this._browseTag ?? browseTag - ); + this.plugin.openTagListModal(this._browseTag ?? browseTag); }) ); } @@ -1158,14 +993,8 @@ var BADGE_STYLE = ` `; var TagsPlugin = class extends Plugin { async onload() { - const saved = await this.loadLocalData(); - this.data = { - ...DEFAULT_DATA, - ...saved ?? {}, - keys: saved?.keys ?? [], - rkeyIndex: saved?.rkeyIndex ?? {}, - didIndex: saved?.didIndex ?? {} - }; + const saved = await this.loadData(); + this.data = { ...DEFAULT_DATA, ...saved ?? {} }; new StyleSnippet(BADGE_STYLE); this.addSettingTab(new TagsSettingTab()); this.app.on("profile-context-menu", (menu, profile) => { @@ -1185,201 +1014,63 @@ var TagsPlugin = class extends Plugin { "author-badges", (context) => this.renderBadges(context.did) ); - this.syncFromPds().then(() => this.refreshSlot("author-badges")); } async persist() { - await this.saveLocalData(this.data); - } - getKeys() { - return this.data.keys; + await this.saveData(this.data); } - getKeyById(id) { - return this.data.keys.find((key) => key.id === id) ?? null; - } - getDisplayKeyId() { - return this.data.displayKeyId; + // Every distinct tag seen across all tagged accounts, for populating the + // "browse by tag" dropdown. + getAllTags() { + const tags = /* @__PURE__ */ new Set(); + for (const entry of Object.values(this.data.tags)) { + for (const tag of entry.tags) tags.add(tag); + } + return [...tags].sort(); } - async addKey(label, secret) { - const key = { id: newKeyId(), label, secret }; - this.data.keys.push(key); - if (!this.data.displayKeyId) this.data.displayKeyId = key.id; - await this.persist(); - this.refreshSlot("author-badges"); - return key; + // Every DID tagged with `tag`. + getDidsForTag(tag) { + return Object.entries(this.data.tags).filter(([, entry]) => entry.tags.includes(tag)).map(([did, entry]) => ({ did, tags: entry.tags })); } - async removeKey(id) { - this.data.keys = this.data.keys.filter((key) => key.id !== id); - delete this.data.didIndex[id]; - if (this.data.displayKeyId === id) { - this.data.displayKeyId = this.data.keys[0]?.id ?? null; - } + async saveTags(did, tags) { + this.data.tags[did] = { tags, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }; await this.persist(); this.refreshSlot("author-badges"); } - async setDisplayKey(id) { - this.data.displayKeyId = id || null; + async removeTags(did) { + delete this.data.tags[did]; await this.persist(); this.refreshSlot("author-badges"); } - // Records that `did`'s tags under `keyId` are `tags` in the reverse - // (did -> tags) index used for "who's tagged X" browsing. Returns - // whether anything actually changed, so callers on a hot path ( - // renderBadges) can skip persisting when nothing did. - recordDidTags(keyId, did, tags, updatedAt) { - this.data.didIndex[keyId] ??= {}; - const existing = this.data.didIndex[keyId][did]; - const unchanged = existing && existing.updatedAt === updatedAt && existing.tags.length === tags.length && existing.tags.every((tag, i) => tag === tags[i]); - if (unchanged) return false; - if (tags.length === 0) { - delete this.data.didIndex[keyId][did]; - } else { - this.data.didIndex[keyId][did] = { tags, updatedAt }; - } - return true; - } - // Every distinct tag this device has seen under `keyId`, for populating - // the "browse by tag" dropdown. Best-effort — see didIndex's own comment. - getAllTagsForKey(keyId) { - const forKey = this.data.didIndex[keyId] ?? {}; - const tags = /* @__PURE__ */ new Set(); - for (const entry of Object.values(forKey)) { - for (const tag of entry.tags) tags.add(tag); - } - return [...tags].sort(); - } - // Every DID this device has seen tagged with `tag` under `keyId`. - // Best-effort — see didIndex's own comment. - getDidsForTag(keyId, tag) { - const forKey = this.data.didIndex[keyId] ?? {}; - return Object.entries(forKey).filter(([, entry]) => entry.tags.includes(tag)).map(([did, entry]) => ({ did, tags: entry.tags })); - } // Resolves display names/handles before ever constructing the modal — // Modal.open() serializes its content synchronously right after onOpen() // runs, so any async work has to happen before .open() is called, not - // inside onOpen. - async openTagListModal(keyId, tag) { - const entries = await Promise.all( - this.getDidsForTag(keyId, tag).map(async ({ did, tags }) => ({ + // inside onOpen. Shared by the initial open and by in-place refreshes + // (via TagListModal.render()/update()) after a tag is removed. + async fetchTagListEntries(tag) { + return Promise.all( + this.getDidsForTag(tag).map(async ({ did, tags }) => ({ did, tags, label: await describeProfile(this.app, did) })) ); - new TagListModal(this, keyId, tag, entries).open(); - } - // Rebuilds the local rkey index from scratch from every one of this - // plugin's own records on the PDS (the host filters listRecords to just - // this plugin's $plugin-stamped records already). One flat index covers - // every saved key at once, so switching the display key or adding a new - // key never needs its own sync. Leaves the existing (possibly stale) - // index in place on failure rather than losing badge data over a - // transient error. Doesn't touch didIndex — a bulk sync fundamentally - // can't recover which DID a record is for. - async syncFromPds() { - try { - const rkeyIndex = {}; - let cursor; - do { - const page = await this.app.data.listRecords(cursor, 100); - for (const record of page?.records ?? []) { - const rkey = record.uri.split("/").pop(); - rkeyIndex[rkey] = { - tags: record.value?.tags ?? [], - updatedAt: record.value?.updatedAt ?? null - }; - } - cursor = page?.cursor; - } while (cursor); - this.data.rkeyIndex = rkeyIndex; - await this.persist(); - } catch (error) { - console.error("Tags: failed to sync from PDS", error); - } - } - // Live per-DID lookup, used by the edit modal (a deliberate, infrequent - // action) rather than badge rendering (the hot path listRecords/ - // syncFromPds exists to avoid). Opportunistically updates both local - // indexes with whatever it learns, at no extra cost. - async fetchTags(keyId, did) { - const key = this.getKeyById(keyId); - if (!key) return { tags: [], updatedAt: null }; - const rkey = await hmacSha256Hex(key.secret, did); - let record = null; - try { - record = await this.app.data.getRecord( - this.app.currentUser.did, - SHARED_COLLECTION, - rkey - ); - } catch { - record = null; - } - const tags = record?.value?.tags ?? []; - const updatedAt = record?.value?.updatedAt ?? null; - this.data.rkeyIndex[rkey] = { tags, updatedAt }; - this.recordDidTags(keyId, did, tags, updatedAt); - await this.persist(); - return { tags, updatedAt }; - } - async saveTags(keyId, did, tags) { - const key = this.getKeyById(keyId); - if (!key) return; - const rkey = await hmacSha256Hex(key.secret, did); - const updatedAt = (/* @__PURE__ */ new Date()).toISOString(); - await this.app.data.putRecord(rkey, { tags, updatedAt }); - this.data.rkeyIndex[rkey] = { tags, updatedAt }; - this.recordDidTags(keyId, did, tags, updatedAt); - await this.persist(); - this.refreshSlot("author-badges"); } - async removeTags(keyId, did) { - const key = this.getKeyById(keyId); - if (!key) return; - const rkey = await hmacSha256Hex(key.secret, did); - try { - await this.app.data.deleteRecord(rkey); - } catch { - } - delete this.data.rkeyIndex[rkey]; - this.recordDidTags(keyId, did, [], null); - await this.persist(); - this.refreshSlot("author-badges"); + async openTagListModal(tag) { + const entries = await this.fetchTagListEntries(tag); + new TagListModal(this, tag, entries).open(); } async openEditModal(did) { - const keys = this.getKeys(); - if (keys.length === 0) { - new Notice("Add a key in the Tags plugin settings first.", 4e3); - return; - } - const keyId = this.getDisplayKeyId() ?? keys[0].id; - const key = this.getKeyById(keyId); - const rkey = await hmacSha256Hex(key.secret, did); - const cached = this.data.rkeyIndex[rkey]; - const tags = cached ? cached.tags : (await this.fetchTags(keyId, did)).tags; - new EditTagsModal(this, did, keyId, tags).open(); - } - // Pure local lookup — no network call, so this is cheap to invoke for - // every post/profile rendered. Relies on syncFromPds (run once at - // startup) having populated rkeyIndex. Opportunistically records the - // did<->tags correlation this render just discovered into didIndex, so - // "browse by tag" can find accounts encountered this way too, not just - // ones explicitly edited. - async renderBadges(did) { + const tags = this.data.tags[did]?.tags ?? []; + new EditTagsModal(this, did, tags).open(); + } + renderBadges(did) { if (!did) return null; - const keyId = this.getDisplayKeyId(); - if (!keyId) return null; - const key = this.getKeyById(keyId); - if (!key) return null; - const rkey = await hmacSha256Hex(key.secret, did); - const entry = this.data.rkeyIndex[rkey]; + const entry = this.data.tags[did]; if (!entry || entry.tags.length === 0) return null; - if (this.recordDidTags(keyId, did, entry.tags, entry.updatedAt)) { - await this.persist(); - } const container = new VirtualEl("div"); container.addClass("tag-badges"); for (const tag of entry.tags) { - container.createEl("span", { text: tag, cls: "tag-badge" }).onClick(() => this.openTagListModal(keyId, tag)); + container.createEl("span", { text: tag, cls: "tag-badge" }).onClick(() => this.openTagListModal(tag)); } return container; } diff --git a/manifest.json b/manifest.json index 6c302b0..ccb6cb4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,7 @@ { "id": "tags", "name": "Tags", - "version": "0.1.0", + "version": "0.2.0", "author": "Impro", - "description": "Privately tag accounts and see your tags next to them. Tags are stored as HMAC-keyed records on your own PDS, keyed by a secret you hold locally — no one else can tell which account a given record describes without your key.", - "permissions": { - "records": ["write"] - } + "description": "Privately tag accounts and see your tags next to them. Tags are stored in your account's private plugin settings — synced across your devices, visible only to you." } diff --git a/package.json b/package.json index 3b8efdf..b5e2d78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tags", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "start": "npm run watch", diff --git a/src/main.js b/src/main.js index 0cd5978..ef34849 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,5 @@ import { Modal, - Notice, Plugin, PluginSettingTab, Setting, @@ -8,81 +7,17 @@ import { VirtualEl, } from "@impro.social/impro-plugin"; -// Every records-write plugin shares this one collection — Impro's own -// OAuth scope only ever grants write access to this exact NSID, so a -// plugin doesn't get to pick its own (see SHARED_PLUGIN_RECORDS_COLLECTION -// in impro core's pluginPermissions.js). The host enforces per-record -// ownership within it (see pluginService.js's putRecord/deleteRecord), so -// this doesn't risk collisions with other plugins' records. -// The host always stamps every record's $type to this same value (AT -// Protocol requires $type to equal the record's containing collection) and -// adds its own $plugin ownership marker — this plugin doesn't set either. -// -// Records are keyed by rkey = hex(HMAC-SHA256(secret, did)). The record -// value never contains the DID it describes — that's the whole point: -// without the secret that produced a given rkey, nobody (not even someone -// who can read the whole public repo) can tell which account a record is -// about. -const SHARED_COLLECTION = "social.impro.plugins.cloaca"; - +// Tags are stored via loadData()/saveData() — private per-account storage +// that syncs across every device signed into this account (it round-trips +// through the account's own AT-Proto preferences, not a public PDS record), +// scoped to this plugin only. There's no manifest permission for it and no +// separate local-only tier needed here: the whole tag map is small enough +// to load and persist as one blob. const DEFAULT_DATA = { - // { id, label, secret }[] — never synced to the PDS or account - // preferences, only ever stored via loadLocalData/saveLocalData (see - // pluginLocalDataStore.js on the host side). - keys: [], - // Which saved key's tags to render as badges. Exactly one at a time, or - // null to show nothing. - displayKeyId: null, - // Local index of every one of this plugin's own records, keyed by rkey - // (not by key id or did — rkeys from different keys/dids never collide - // in practice, since each is its own HMAC output, so one flat map covers - // every saved key at once). This is what makes badge rendering a pure - // local lookup instead of a network round trip per post: it's rebuilt - // wholesale from a paged listRecords call once at startup (see - // syncFromPds), then kept current incrementally by saveTags/removeTags/ - // fetchTags. It's a local index only — the PDS has no way to list "every - // DID I've tagged" itself, since a record's rkey reveals nothing about - // the DID it's for — so this can drift if tags are edited from another - // device/app between syncs; the PDS record is always the source of - // truth. - rkeyIndex: {}, - // The reverse of rkeyIndex — { [keyId]: { [did]: { tags, updatedAt } } } - // — used to answer "who's tagged X". Unlike rkeyIndex, this can only ever - // be built for DIDs this plugin has actually resolved on this device (by - // rendering a badge for them under their key, or via the edit modal) — - // there's no way to recover a DID from a record's rkey/value, by design, - // so a bulk PDS sync can never populate this. "Everyone tagged X" is - // therefore best-effort: complete for accounts you've encountered here, - // not necessarily complete for ones tagged from another device that - // haven't been seen on this one since. - didIndex: {}, + // { [did]: { tags: string[], updatedAt: string } } + tags: {}, }; -function toHex(bytes) { - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); -} - -function randomSecret() { - return toHex(crypto.getRandomValues(new Uint8Array(24))); -} - -function newKeyId() { - return toHex(crypto.getRandomValues(new Uint8Array(8))); -} - -async function hmacSha256Hex(secret, message) { - const bytes = new TextEncoder().encode(message); - const cryptoKey = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign("HMAC", cryptoKey, bytes); - return toHex(new Uint8Array(signature)); -} - function parseTagsInput(text) { const seen = new Set(); for (const raw of text.split(",")) { @@ -107,34 +42,16 @@ async function describeProfile(app, did) { } class EditTagsModal extends Modal { - constructor(plugin, did, keyId, tags) { + constructor(plugin, did, tags) { super(); this.plugin = plugin; this.did = did; - this.keyId = keyId; this.tags = tags; this._tagsText = tags.join(", "); } onOpen() { this.titleEl.setText("Edit tags"); - const keys = this.plugin.getKeys(); - - new Setting(this.contentEl) - .setName("Key") - .setDesc("Which local key this tag is stored under") - .addDropdown((dropdown) => { - dropdown.addOptions( - Object.fromEntries(keys.map((key) => [key.id, key.label])), - ); - dropdown.setValue(this.keyId); - dropdown.onChange(async (value) => { - if (value === this.keyId) return; - this.close(); - const { tags } = await this.plugin.fetchTags(value, this.did); - new EditTagsModal(this.plugin, this.did, value, tags).open(); - }); - }); new Setting(this.contentEl) .setName("Tags") @@ -156,7 +73,7 @@ class EditTagsModal extends Modal { }) .onClick(async () => { const tags = parseTagsInput(this._tagsText); - await this.plugin.saveTags(this.keyId, this.did, tags); + await this.plugin.saveTags(this.did, tags); this.close(); }); if (this.tags.length > 0) { @@ -166,7 +83,7 @@ class EditTagsModal extends Modal { cls: "modal-dialog-button danger-button", }) .onClick(async () => { - await this.plugin.removeTags(this.keyId, this.did); + await this.plugin.removeTags(this.did); this.close(); }); } @@ -181,21 +98,25 @@ class EditTagsModal extends Modal { } } -// entries is pre-resolved ({ did, tags, label }[]) — built before this -// modal is ever constructed, since Modal.open() serializes contentEl -// synchronously right after onOpen() runs, with no way to await async work -// inside onOpen itself (see plugin.openTagListModal). class TagListModal extends Modal { - constructor(plugin, keyId, tag, entries) { + constructor(plugin, tag, entries) { super(); this.plugin = plugin; - this.keyId = keyId; this.tag = tag; this.entries = entries; } onOpen() { this.titleEl.setText(`Tagged "${this.tag}"`); + this.render(); + } + + // Rebuilds contentEl from this.entries. Called once from onOpen, and + // again (followed by this.update()) whenever a tag is removed from + // within the list, so the modal refreshes in place instead of closing + // and reopening. + render() { + this.contentEl.empty(); if (this.entries.length === 0) { this.contentEl.createEl("p", { text: "No accounts found on this device for this tag.", @@ -209,7 +130,7 @@ class TagListModal extends Modal { .createEl("button", { text: "Edit", cls: "modal-dialog-button" }) .onClick(() => { this.close(); - new EditTagsModal(this.plugin, did, this.keyId, tags).open(); + new EditTagsModal(this.plugin, did, tags).open(); }); row .createEl("button", { @@ -219,12 +140,13 @@ class TagListModal extends Modal { .onClick(async () => { const updated = tags.filter((t) => t !== this.tag); if (updated.length === 0) { - await this.plugin.removeTags(this.keyId, did); + await this.plugin.removeTags(did); } else { - await this.plugin.saveTags(this.keyId, did, updated); + await this.plugin.saveTags(did, updated); } - this.close(); - this.plugin.openTagListModal(this.keyId, this.tag); + this.entries = await this.plugin.fetchTagListEntries(this.tag); + this.render(); + this.update(); }); } const buttons = this.contentEl.createDiv({ cls: "modal-dialog-buttons" }); @@ -243,127 +165,35 @@ class TagsSettingTab extends PluginSettingTab { constructor() { super(); this.setName("Tags"); - this._newLabel = ""; - this._newSecret = ""; - this._browseKeyId = null; this._browseTag = null; } display() { - const keys = this.plugin.getKeys(); - const displayKeyId = this.plugin.getDisplayKeyId(); + const tags = this.plugin.getAllTags(); - new Setting(this.containerEl) - .setName("Display key") - .setDesc( - "Which key's tags to show as badges next to accounts. Only tags saved under this key are visible to you here — nothing about the other keys is revealed.", - ) - .addDropdown((dropdown) => { - dropdown.addOptions({ - "": "None", - ...Object.fromEntries(keys.map((key) => [key.id, key.label])), - }); - dropdown.setValue(displayKeyId ?? ""); - dropdown.onChange(async (value) => { - await this.plugin.setDisplayKey(value || null); - }); - }); - - this.containerEl.createEl("h2", { - text: "Keys", - cls: "setting-item-name", + this.containerEl.createEl("p", { + text: "Tag accounts from the post/profile menu — tags are private to you and synced across your devices.", + cls: "setting-item-desc", }); - if (keys.length === 0) { + if (tags.length === 0) { this.containerEl.createEl("p", { - text: "No keys yet. Add one below to start tagging accounts.", + text: "No tags yet. Tag an account from its profile or post menu to get started.", cls: "setting-item-desc", }); + return; } - for (const key of keys) { - new Setting(this.containerEl) - .setName(key.label) - .setDesc(key.id === displayKeyId ? "Current display key" : "") - .addButton((button) => - button.setButtonText("Delete").onClick(async () => { - await this.plugin.removeKey(key.id); - this.refresh({ reset: true }); - }), - ); - } - - new Setting(this.containerEl) - .setName("New key label") - .setDesc("A name to help you tell your keys apart") - .addText((text) => - text.setPlaceholder("personal").onChange((value) => { - this._newLabel = value; - }), - ); - - new Setting(this.containerEl) - .setName("New key secret") - .setDesc( - "Leave blank to generate a new random one, or paste an existing key to share tags with another device or app that uses the same scheme.", - ) - .addText((text) => - text.setPlaceholder("leave blank to generate").onChange((value) => { - this._newSecret = value; - }), - ); - - new Setting(this.containerEl).addButton((button) => - button - .setButtonText("Add key") - .setCta() - .onClick(async () => { - const label = this._newLabel.trim() || "Untitled key"; - const secret = this._newSecret.trim() || randomSecret(); - this._newLabel = ""; - this._newSecret = ""; - await this.plugin.addKey(label, secret); - this.refresh({ reset: true }); - }), - ); - - if (keys.length === 0) return; - this.containerEl.createEl("h2", { text: "Browse by tag", cls: "setting-item-name", }); - this.containerEl.createEl("p", { - text: "Shows accounts tagged on this device, under any saved key — not necessarily every account ever tagged under that key (see the README).", - cls: "setting-item-desc", - }); - const browseKeyId = this._browseKeyId ?? displayKeyId ?? keys[0].id; - new Setting(this.containerEl).setName("Key").addDropdown((dropdown) => { - dropdown.addOptions( - Object.fromEntries(keys.map((key) => [key.id, key.label])), - ); - dropdown.setValue(browseKeyId); - dropdown.onChange((value) => { - this._browseKeyId = value; - this._browseTag = null; - this.refresh({ reset: true }); - }); - }); - - const tagsForKey = this.plugin.getAllTagsForKey(browseKeyId); - if (tagsForKey.length === 0) { - this.containerEl.createEl("p", { - text: "No tags seen yet for this key on this device.", - cls: "setting-item-desc", - }); - return; - } - const browseTag = this._browseTag ?? tagsForKey[0]; + const browseTag = this._browseTag ?? tags[0]; new Setting(this.containerEl) .setName("Tag") .addDropdown((dropdown) => { - dropdown.addOptions(Object.fromEntries(tagsForKey.map((t) => [t, t]))); + dropdown.addOptions(Object.fromEntries(tags.map((t) => [t, t]))); dropdown.setValue(browseTag); dropdown.onChange((value) => { this._browseTag = value; @@ -371,10 +201,7 @@ class TagsSettingTab extends PluginSettingTab { }) .addButton((button) => button.setButtonText("View").onClick(() => { - this.plugin.openTagListModal( - browseKeyId, - this._browseTag ?? browseTag, - ); + this.plugin.openTagListModal(this._browseTag ?? browseTag); }), ); } @@ -401,14 +228,8 @@ const BADGE_STYLE = ` export default class TagsPlugin extends Plugin { async onload() { - const saved = await this.loadLocalData(); - this.data = { - ...DEFAULT_DATA, - ...(saved ?? {}), - keys: saved?.keys ?? [], - rkeyIndex: saved?.rkeyIndex ?? {}, - didIndex: saved?.didIndex ?? {}, - }; + const saved = await this.loadData(); + this.data = { ...DEFAULT_DATA, ...(saved ?? {}) }; new StyleSnippet(BADGE_STYLE); this.addSettingTab(new TagsSettingTab()); @@ -437,231 +258,76 @@ export default class TagsPlugin extends Plugin { this.registerSlot("author-badges", (context) => this.renderBadges(context.did), ); - - // Badges render from whatever's already in the local index in the - // meantime (instant on every session after the first) — this just - // refreshes it in the background and redraws anything that changed. - this.syncFromPds().then(() => this.refreshSlot("author-badges")); } async persist() { - await this.saveLocalData(this.data); - } - - getKeys() { - return this.data.keys; + await this.saveData(this.data); } - getKeyById(id) { - return this.data.keys.find((key) => key.id === id) ?? null; - } - - getDisplayKeyId() { - return this.data.displayKeyId; + // Every distinct tag seen across all tagged accounts, for populating the + // "browse by tag" dropdown. + getAllTags() { + const tags = new Set(); + for (const entry of Object.values(this.data.tags)) { + for (const tag of entry.tags) tags.add(tag); + } + return [...tags].sort(); } - async addKey(label, secret) { - const key = { id: newKeyId(), label, secret }; - this.data.keys.push(key); - if (!this.data.displayKeyId) this.data.displayKeyId = key.id; - await this.persist(); - this.refreshSlot("author-badges"); - return key; + // Every DID tagged with `tag`. + getDidsForTag(tag) { + return Object.entries(this.data.tags) + .filter(([, entry]) => entry.tags.includes(tag)) + .map(([did, entry]) => ({ did, tags: entry.tags })); } - async removeKey(id) { - this.data.keys = this.data.keys.filter((key) => key.id !== id); - delete this.data.didIndex[id]; - if (this.data.displayKeyId === id) { - this.data.displayKeyId = this.data.keys[0]?.id ?? null; - } + async saveTags(did, tags) { + this.data.tags[did] = { tags, updatedAt: new Date().toISOString() }; await this.persist(); this.refreshSlot("author-badges"); } - async setDisplayKey(id) { - this.data.displayKeyId = id || null; + async removeTags(did) { + delete this.data.tags[did]; await this.persist(); this.refreshSlot("author-badges"); } - // Records that `did`'s tags under `keyId` are `tags` in the reverse - // (did -> tags) index used for "who's tagged X" browsing. Returns - // whether anything actually changed, so callers on a hot path ( - // renderBadges) can skip persisting when nothing did. - recordDidTags(keyId, did, tags, updatedAt) { - this.data.didIndex[keyId] ??= {}; - const existing = this.data.didIndex[keyId][did]; - const unchanged = - existing && - existing.updatedAt === updatedAt && - existing.tags.length === tags.length && - existing.tags.every((tag, i) => tag === tags[i]); - if (unchanged) return false; - if (tags.length === 0) { - delete this.data.didIndex[keyId][did]; - } else { - this.data.didIndex[keyId][did] = { tags, updatedAt }; - } - return true; - } - - // Every distinct tag this device has seen under `keyId`, for populating - // the "browse by tag" dropdown. Best-effort — see didIndex's own comment. - getAllTagsForKey(keyId) { - const forKey = this.data.didIndex[keyId] ?? {}; - const tags = new Set(); - for (const entry of Object.values(forKey)) { - for (const tag of entry.tags) tags.add(tag); - } - return [...tags].sort(); - } - - // Every DID this device has seen tagged with `tag` under `keyId`. - // Best-effort — see didIndex's own comment. - getDidsForTag(keyId, tag) { - const forKey = this.data.didIndex[keyId] ?? {}; - return Object.entries(forKey) - .filter(([, entry]) => entry.tags.includes(tag)) - .map(([did, entry]) => ({ did, tags: entry.tags })); - } - // Resolves display names/handles before ever constructing the modal — // Modal.open() serializes its content synchronously right after onOpen() // runs, so any async work has to happen before .open() is called, not - // inside onOpen. - async openTagListModal(keyId, tag) { - const entries = await Promise.all( - this.getDidsForTag(keyId, tag).map(async ({ did, tags }) => ({ + // inside onOpen. Shared by the initial open and by in-place refreshes + // (via TagListModal.render()/update()) after a tag is removed. + async fetchTagListEntries(tag) { + return Promise.all( + this.getDidsForTag(tag).map(async ({ did, tags }) => ({ did, tags, label: await describeProfile(this.app, did), })), ); - new TagListModal(this, keyId, tag, entries).open(); - } - - // Rebuilds the local rkey index from scratch from every one of this - // plugin's own records on the PDS (the host filters listRecords to just - // this plugin's $plugin-stamped records already). One flat index covers - // every saved key at once, so switching the display key or adding a new - // key never needs its own sync. Leaves the existing (possibly stale) - // index in place on failure rather than losing badge data over a - // transient error. Doesn't touch didIndex — a bulk sync fundamentally - // can't recover which DID a record is for. - async syncFromPds() { - try { - const rkeyIndex = {}; - let cursor; - do { - const page = await this.app.data.listRecords(cursor, 100); - for (const record of page?.records ?? []) { - const rkey = record.uri.split("/").pop(); - rkeyIndex[rkey] = { - tags: record.value?.tags ?? [], - updatedAt: record.value?.updatedAt ?? null, - }; - } - cursor = page?.cursor; - } while (cursor); - this.data.rkeyIndex = rkeyIndex; - await this.persist(); - } catch (error) { - console.error("Tags: failed to sync from PDS", error); - } - } - - // Live per-DID lookup, used by the edit modal (a deliberate, infrequent - // action) rather than badge rendering (the hot path listRecords/ - // syncFromPds exists to avoid). Opportunistically updates both local - // indexes with whatever it learns, at no extra cost. - async fetchTags(keyId, did) { - const key = this.getKeyById(keyId); - if (!key) return { tags: [], updatedAt: null }; - const rkey = await hmacSha256Hex(key.secret, did); - let record = null; - try { - record = await this.app.data.getRecord( - this.app.currentUser.did, - SHARED_COLLECTION, - rkey, - ); - } catch { - record = null; - } - const tags = record?.value?.tags ?? []; - const updatedAt = record?.value?.updatedAt ?? null; - this.data.rkeyIndex[rkey] = { tags, updatedAt }; - this.recordDidTags(keyId, did, tags, updatedAt); - await this.persist(); - return { tags, updatedAt }; } - async saveTags(keyId, did, tags) { - const key = this.getKeyById(keyId); - if (!key) return; - const rkey = await hmacSha256Hex(key.secret, did); - const updatedAt = new Date().toISOString(); - await this.app.data.putRecord(rkey, { tags, updatedAt }); - this.data.rkeyIndex[rkey] = { tags, updatedAt }; - this.recordDidTags(keyId, did, tags, updatedAt); - await this.persist(); - this.refreshSlot("author-badges"); - } - - async removeTags(keyId, did) { - const key = this.getKeyById(keyId); - if (!key) return; - const rkey = await hmacSha256Hex(key.secret, did); - try { - await this.app.data.deleteRecord(rkey); - } catch { - // Already gone — fine. - } - delete this.data.rkeyIndex[rkey]; - this.recordDidTags(keyId, did, [], null); - await this.persist(); - this.refreshSlot("author-badges"); + async openTagListModal(tag) { + const entries = await this.fetchTagListEntries(tag); + new TagListModal(this, tag, entries).open(); } async openEditModal(did) { - const keys = this.getKeys(); - if (keys.length === 0) { - new Notice("Add a key in the Tags plugin settings first.", 4000); - return; - } - const keyId = this.getDisplayKeyId() ?? keys[0].id; - const key = this.getKeyById(keyId); - const rkey = await hmacSha256Hex(key.secret, did); - const cached = this.data.rkeyIndex[rkey]; - const tags = cached ? cached.tags : (await this.fetchTags(keyId, did)).tags; - new EditTagsModal(this, did, keyId, tags).open(); + const tags = this.data.tags[did]?.tags ?? []; + new EditTagsModal(this, did, tags).open(); } - // Pure local lookup — no network call, so this is cheap to invoke for - // every post/profile rendered. Relies on syncFromPds (run once at - // startup) having populated rkeyIndex. Opportunistically records the - // did<->tags correlation this render just discovered into didIndex, so - // "browse by tag" can find accounts encountered this way too, not just - // ones explicitly edited. - async renderBadges(did) { + renderBadges(did) { if (!did) return null; - const keyId = this.getDisplayKeyId(); - if (!keyId) return null; - const key = this.getKeyById(keyId); - if (!key) return null; - const rkey = await hmacSha256Hex(key.secret, did); - const entry = this.data.rkeyIndex[rkey]; + const entry = this.data.tags[did]; if (!entry || entry.tags.length === 0) return null; - if (this.recordDidTags(keyId, did, entry.tags, entry.updatedAt)) { - await this.persist(); - } const container = new VirtualEl("div"); container.addClass("tag-badges"); for (const tag of entry.tags) { container .createEl("span", { text: tag, cls: "tag-badge" }) - .onClick(() => this.openTagListModal(keyId, tag)); + .onClick(() => this.openTagListModal(tag)); } return container; }