diff --git a/README.md b/README.md index 2be030a..caaa24b 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,19 @@ it. key to write under, enter comma-separated tags, and save. 3. Your tags show up as small pills next to that account everywhere labels would — profile header, posts, quoted posts, and the DM info panel. + +## Browsing by tag + +Click any tag pill to see every account tagged with it (under the key that +badge is showing), with **Edit** and **Remove** actions right there. The +plugin's settings have the same view under **Browse by tag** — pick any +saved key, then a tag seen under that key, to view and edit its accounts. + +This view can only ever show accounts this plugin has actually resolved a +DID for on this device — by rendering a badge for them, or by opening the +edit modal — never a complete list of everyone you've ever tagged under a +key. That's the same one-way-hash limitation the local rkey index has (see +above): a record's rkey can be checked for a DID you already have in hand, +but no amount of PDS access can turn an rkey back into the DID it was +computed from. If you tag someone on another device and never see them +here, they won't show up in this view until you do. diff --git a/main.js b/main.js index b9563bb..1524505 100644 --- a/main.js +++ b/main.js @@ -878,7 +878,17 @@ var DEFAULT_DATA = { // 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: {} + 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: {} }; function toHex(bytes) { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); @@ -909,6 +919,16 @@ function parseTagsInput(text) { } return [...seen]; } +async function describeProfile(app, did) { + try { + const profile = await app.data.getProfile(did); + if (profile) { + return profile.displayName ? `${profile.displayName} (@${profile.handle})` : `@${profile.handle}`; + } + } catch { + } + return did; +} var EditTagsModal = class _EditTagsModal extends Modal { constructor(plugin, did, keyId, tags) { super(); @@ -963,12 +983,59 @@ var EditTagsModal = class _EditTagsModal extends Modal { this.contentEl.empty(); } }; +var TagListModal = class extends Modal { + constructor(plugin, keyId, tag, entries) { + super(); + this.plugin = plugin; + this.keyId = keyId; + this.tag = tag; + this.entries = entries; + } + onOpen() { + this.titleEl.setText(`Tagged "${this.tag}"`); + if (this.entries.length === 0) { + this.contentEl.createEl("p", { + text: "No accounts found on this device for this tag.", + cls: "modal-dialog-message" + }); + } + for (const { did, tags, label } of this.entries) { + const row = this.contentEl.createDiv({ cls: "tag-list-row" }); + 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(); + }); + row.createEl("button", { + text: `Remove "${this.tag}"`, + cls: "modal-dialog-button danger-button" + }).onClick(async () => { + const updated = tags.filter((t) => t !== this.tag); + if (updated.length === 0) { + await this.plugin.removeTags(this.keyId, did); + } else { + await this.plugin.saveTags(this.keyId, did, updated); + } + this.close(); + this.plugin.openTagListModal(this.keyId, this.tag); + }); + } + const buttons = this.contentEl.createDiv({ cls: "modal-dialog-buttons" }); + buttons.createEl("button", { text: "Close", cls: "modal-dialog-button" }).onClick(() => this.close()); + } + onClose() { + this.titleEl.empty(); + this.contentEl.empty(); + } +}; 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(); @@ -1025,6 +1092,50 @@ var TagsSettingTab = class extends PluginSettingTab { 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]; + new Setting(this.containerEl).setName("Tag").addDropdown((dropdown) => { + dropdown.addOptions(Object.fromEntries(tagsForKey.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 + ); + }) + ); } }; var BADGE_STYLE = ` @@ -1035,7 +1146,15 @@ var BADGE_STYLE = ` border-radius: 999px; font-size: 0.8em; background: color-mix(in srgb, currentColor 12%, transparent); + cursor: pointer; +} +.tag-list-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; } +.tag-list-row-name { flex: 1; } `; var TagsPlugin = class extends Plugin { async onload() { @@ -1044,7 +1163,8 @@ var TagsPlugin = class extends Plugin { ...DEFAULT_DATA, ...saved ?? {}, keys: saved?.keys ?? [], - rkeyIndex: saved?.rkeyIndex ?? {} + rkeyIndex: saved?.rkeyIndex ?? {}, + didIndex: saved?.didIndex ?? {} }; new StyleSnippet(BADGE_STYLE); this.addSettingTab(new TagsSettingTab()); @@ -1089,6 +1209,7 @@ var TagsPlugin = class extends Plugin { } 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; } @@ -1100,13 +1221,60 @@ var TagsPlugin = class extends Plugin { 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 }) => ({ + 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. + // transient error. Doesn't touch didIndex — a bulk sync fundamentally + // can't recover which DID a record is for. async syncFromPds() { try { const rkeyIndex = {}; @@ -1130,8 +1298,8 @@ var TagsPlugin = class extends Plugin { } // 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 the local - // index with whatever it learns, at no extra cost. + // 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 }; @@ -1149,6 +1317,7 @@ var TagsPlugin = class extends Plugin { 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 }; } @@ -1159,6 +1328,7 @@ var TagsPlugin = class extends Plugin { 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"); } @@ -1171,6 +1341,7 @@ var TagsPlugin = class extends Plugin { } catch { } delete this.data.rkeyIndex[rkey]; + this.recordDidTags(keyId, did, [], null); await this.persist(); this.refreshSlot("author-badges"); } @@ -1189,7 +1360,10 @@ var TagsPlugin = class extends Plugin { } // Pure local lookup — no network call, so this is cheap to invoke for // every post/profile rendered. Relies on syncFromPds (run once at - // startup, and after every write) having populated rkeyIndex. + // 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) { if (!did) return null; const keyId = this.getDisplayKeyId(); @@ -1199,10 +1373,13 @@ var TagsPlugin = class extends Plugin { const rkey = await hmacSha256Hex(key.secret, did); const entry = this.data.rkeyIndex[rkey]; 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" }); + container.createEl("span", { text: tag, cls: "tag-badge" }).onClick(() => this.openTagListModal(keyId, tag)); } return container; } diff --git a/src/main.js b/src/main.js index c5d21ae..0cd5978 100644 --- a/src/main.js +++ b/src/main.js @@ -46,6 +46,16 @@ const DEFAULT_DATA = { // 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: {}, }; function toHex(bytes) { @@ -82,6 +92,20 @@ function parseTagsInput(text) { return [...seen]; } +async function describeProfile(app, did) { + try { + const profile = await app.data.getProfile(did); + if (profile) { + return profile.displayName + ? `${profile.displayName} (@${profile.handle})` + : `@${profile.handle}`; + } + } catch { + // fall through to the raw DID + } + return did; +} + class EditTagsModal extends Modal { constructor(plugin, did, keyId, tags) { super(); @@ -157,12 +181,72 @@ 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) { + super(); + this.plugin = plugin; + this.keyId = keyId; + this.tag = tag; + this.entries = entries; + } + + onOpen() { + this.titleEl.setText(`Tagged "${this.tag}"`); + if (this.entries.length === 0) { + this.contentEl.createEl("p", { + text: "No accounts found on this device for this tag.", + cls: "modal-dialog-message", + }); + } + for (const { did, tags, label } of this.entries) { + const row = this.contentEl.createDiv({ cls: "tag-list-row" }); + 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(); + }); + row + .createEl("button", { + text: `Remove "${this.tag}"`, + cls: "modal-dialog-button danger-button", + }) + .onClick(async () => { + const updated = tags.filter((t) => t !== this.tag); + if (updated.length === 0) { + await this.plugin.removeTags(this.keyId, did); + } else { + await this.plugin.saveTags(this.keyId, did, updated); + } + this.close(); + this.plugin.openTagListModal(this.keyId, this.tag); + }); + } + const buttons = this.contentEl.createDiv({ cls: "modal-dialog-buttons" }); + buttons + .createEl("button", { text: "Close", cls: "modal-dialog-button" }) + .onClick(() => this.close()); + } + + onClose() { + this.titleEl.empty(); + this.contentEl.empty(); + } +} + class TagsSettingTab extends PluginSettingTab { constructor() { super(); this.setName("Tags"); this._newLabel = ""; this._newSecret = ""; + this._browseKeyId = null; + this._browseTag = null; } display() { @@ -242,6 +326,57 @@ class TagsSettingTab extends PluginSettingTab { 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]; + new Setting(this.containerEl) + .setName("Tag") + .addDropdown((dropdown) => { + dropdown.addOptions(Object.fromEntries(tagsForKey.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, + ); + }), + ); } } @@ -253,7 +388,15 @@ const BADGE_STYLE = ` border-radius: 999px; font-size: 0.8em; background: color-mix(in srgb, currentColor 12%, transparent); + cursor: pointer; } +.tag-list-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; +} +.tag-list-row-name { flex: 1; } `; export default class TagsPlugin extends Plugin { @@ -264,6 +407,7 @@ export default class TagsPlugin extends Plugin { ...(saved ?? {}), keys: saved?.keys ?? [], rkeyIndex: saved?.rkeyIndex ?? {}, + didIndex: saved?.didIndex ?? {}, }; new StyleSnippet(BADGE_STYLE); @@ -327,6 +471,7 @@ export default class TagsPlugin extends Plugin { 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; } @@ -340,13 +485,70 @@ export default class TagsPlugin extends Plugin { 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 }) => ({ + 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. + // transient error. Doesn't touch didIndex — a bulk sync fundamentally + // can't recover which DID a record is for. async syncFromPds() { try { const rkeyIndex = {}; @@ -371,8 +573,8 @@ export default class TagsPlugin extends Plugin { // 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 the local - // index with whatever it learns, at no extra cost. + // 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 }; @@ -390,6 +592,7 @@ export default class TagsPlugin extends Plugin { 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 }; } @@ -401,6 +604,7 @@ export default class TagsPlugin extends Plugin { 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"); } @@ -415,6 +619,7 @@ export default class TagsPlugin extends Plugin { // Already gone — fine. } delete this.data.rkeyIndex[rkey]; + this.recordDidTags(keyId, did, [], null); await this.persist(); this.refreshSlot("author-badges"); } @@ -435,7 +640,10 @@ export default class TagsPlugin extends Plugin { // Pure local lookup — no network call, so this is cheap to invoke for // every post/profile rendered. Relies on syncFromPds (run once at - // startup, and after every write) having populated rkeyIndex. + // 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) { if (!did) return null; const keyId = this.getDisplayKeyId(); @@ -445,10 +653,15 @@ export default class TagsPlugin extends Plugin { const rkey = await hmacSha256Hex(key.secret, did); const entry = this.data.rkeyIndex[rkey]; 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" }); + container + .createEl("span", { text: tag, cls: "tag-badge" }) + .onClick(() => this.openTagListModal(keyId, tag)); } return container; }