From f27128b3d3037093e3df028bee49ad661cf699a8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:35:49 -0500 Subject: [PATCH] Handle legacy hashtag preference events --- js/services/hashtagPreferencesService.js | 94 +++++++++++++++++------- 1 file changed, 67 insertions(+), 27 deletions(-) diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 2dd34c88..597dde3b 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -116,6 +116,44 @@ function normalizeEncryptionToken(value) { .replace(/[^a-z0-9]/g, ""); } +function normalizePreferencesPayload(payload) { + const versionCandidate = Number(payload?.version); + const version = Number.isFinite(versionCandidate) && versionCandidate > 0 + ? versionCandidate + : DEFAULT_VERSION; + + const rawInterests = Array.isArray(payload?.interests) + ? payload.interests + : []; + const rawDisinterests = Array.isArray(payload?.disinterests) + ? payload.disinterests + : []; + + const interests = new Set(); + for (const tag of rawInterests) { + const normalizedTag = normalizeTag(tag); + if (normalizedTag) { + interests.add(normalizedTag); + } + } + + const disinterests = new Set(); + for (const tag of rawDisinterests) { + const normalizedTag = normalizeTag(tag); + if (!normalizedTag) { + continue; + } + disinterests.add(normalizedTag); + interests.delete(normalizedTag); + } + + return { + version, + interests, + disinterests, + }; +} + function extractEncryptionHints(event) { if (!event || typeof event !== "object") { return []; @@ -205,6 +243,7 @@ class HashtagPreferencesService { this.eventId = null; this.eventCreatedAt = null; this.loaded = false; + this.preferencesVersion = DEFAULT_VERSION; } on(eventName, handler) { @@ -217,6 +256,7 @@ class HashtagPreferencesService { this.eventId = null; this.eventCreatedAt = null; this.loaded = false; + this.preferencesVersion = DEFAULT_VERSION; this.emitChange("reset"); } @@ -294,6 +334,7 @@ class HashtagPreferencesService { disinterests: this.getDisinterests(), eventId: this.eventId, createdAt: this.eventCreatedAt, + version: this.preferencesVersion, ...detail, }); } catch (error) { @@ -336,8 +377,13 @@ class HashtagPreferencesService { } const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES); + const canonicalKind = schema?.kind ?? 30015; + const legacyKind = 30005; + const filterKinds = canonicalKind === legacyKind + ? [canonicalKind] + : [canonicalKind, legacyKind]; const filter = { - kinds: [schema?.kind ?? 30015], + kinds: filterKinds, authors: [normalized], "#d": [HASHTAG_IDENTIFIER], limit: 50, @@ -363,6 +409,15 @@ class HashtagPreferencesService { return; } + // Prefer the newest event, breaking timestamp ties by prioritizing the + // canonical kind (30015) before falling back to the legacy 30005 payload. + const preferredKinds = filterKinds; + const getKindPriority = (event) => { + const kindValue = Number(event?.kind); + const index = preferredKinds.indexOf(kindValue); + return index === -1 ? preferredKinds.length : index; + }; + const latest = events.reduce((current, candidate) => { if (!candidate) { return current; @@ -373,6 +428,11 @@ class HashtagPreferencesService { const candidateTs = Number(candidate.created_at) || 0; const currentTs = Number(current.created_at) || 0; if (candidateTs === currentTs) { + const candidatePriority = getKindPriority(candidate); + const currentPriority = getKindPriority(current); + if (candidatePriority !== currentPriority) { + return candidatePriority < currentPriority ? candidate : current; + } return candidate.id > current.id ? candidate : current; } return candidateTs > currentTs ? candidate : current; @@ -397,33 +457,13 @@ class HashtagPreferencesService { try { const payload = JSON.parse(decryptResult.plaintext); - const rawInterests = Array.isArray(payload?.interests) - ? payload.interests - : []; - const rawDisinterests = Array.isArray(payload?.disinterests) - ? payload.disinterests - : []; + // Normalize whichever source kind we decrypted into the canonical + // preferences payload so downstream consumers receive a single shape. + const normalizedPayload = normalizePreferencesPayload(payload); - const nextInterests = new Set(); - for (const tag of rawInterests) { - const normalizedTag = normalizeTag(tag); - if (normalizedTag) { - nextInterests.add(normalizedTag); - } - } - - const nextDisinterests = new Set(); - for (const tag of rawDisinterests) { - const normalizedTag = normalizeTag(tag); - if (!normalizedTag) { - continue; - } - nextDisinterests.add(normalizedTag); - nextInterests.delete(normalizedTag); - } - - this.interests = nextInterests; - this.disinterests = nextDisinterests; + this.preferencesVersion = normalizedPayload.version; + this.interests = normalizedPayload.interests; + this.disinterests = normalizedPayload.disinterests; this.eventId = latest.id || null; this.eventCreatedAt = Number.isFinite(latest?.created_at) ? latest.created_at