diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 9ac0e714..4c9b0069 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -173,7 +173,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema | DM relay hints (`NOTE_TYPES.DM_RELAY_LIST`) | `10050` | Repeating `['relay', ]` tags to advertise delivery relays for NIP-17 gift-wrapped DMs | Empty content | | View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', ]` or `['a',
]`), and a stable dedupe tag `['d', ]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | | Watch history month (`NOTE_TYPES.WATCH_HISTORY`) | `WATCH_HISTORY_KIND` (default `30079`) | Replaceable list tag `['d', `${WATCH_HISTORY_LIST_IDENTIFIER}:`]` with optional `['month', ]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: , watched_at?: }] }` | -| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', ], …]`) | +| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']`, `['encrypted', 'nip44_v2']` (updated to the negotiated scheme at publish time) | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', ], …]`) | | User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }` | | Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30015` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` | | Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | @@ -210,6 +210,12 @@ events with the shared `['d','subscriptions']` identifier. Builders continue to encrypt the payload so individual follows stay private unless explicitly revealed by the author. +Publishers should include an explicit `['encrypted', '']` tag on +subscription list events, preferring `nip44_v2` (or `nip44`) and falling back to +`nip04` only when no NIP-44 encryptor is available. This mirrors the encrypted +tagging used for hashtag preference lists so consumers can select the correct +decryptor quickly. + NIP-94 compliance: mirror events now normalize the `['m', ]` tag to lowercase—covering both user input and inferred values—so they satisfy [NIP-94's lowercase MIME requirement](./nips/94.md). Related helpers reuse the same normalization to keep future publishers aligned. ### Direct messages diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index fae71355..6174ea34 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -513,7 +513,7 @@ const BASE_SCHEMAS = { name: "d", value: SUBSCRIPTION_LIST_IDENTIFIER, }, - appendTags: DEFAULT_APPEND_TAGS, + appendTags: [["encrypted", "nip44_v2"]], content: { format: "encrypted-tag-list", description: @@ -2405,12 +2405,22 @@ export function buildSubscriptionListEvent(params) { if (identifierName && identifierValue) { tags.push([identifierName, identifierValue]); } + appendSchemaTags(tags, schema); const normalizedEncryption = typeof encryption === "string" ? encryption.trim() : ""; if (normalizedEncryption) { - tags.push(["encrypted", normalizedEncryption]); + let updated = false; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "encrypted") { + tag[1] = normalizedEncryption; + updated = true; + break; + } + } + if (!updated) { + tags.push(["encrypted", normalizedEncryption]); + } } - appendSchemaTags(tags, schema); const event = { kind: schema?.kind ?? 30000, diff --git a/js/subscriptions.js b/js/subscriptions.js index 64a87acf..60ecbb4d 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -40,7 +40,7 @@ import { relaySubscriptionService } from "./services/relaySubscriptionService.js const SUBSCRIPTION_SET_KIND = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; -const DECRYPT_TIMEOUT_MS = 15000; +const DECRYPT_TIMEOUT_MS = 90000; const DECRYPT_RETRY_DELAY_MS = 10000; function normalizeHexPubkey(value) { @@ -114,17 +114,23 @@ function determineDecryptionOrder(event, availableSchemes) { const available = Array.isArray(availableSchemes) ? availableSchemes : []; const availableSet = new Set(available); const prioritized = []; + const hasNip44 = + availableSet.has("nip44_v2") || availableSet.has("nip44"); + const allowNip04 = !hasNip44 && availableSet.has("nip04"); const hints = extractEncryptionHints(event); const aliasMap = { nip04: ["nip04"], - nip44: ["nip44", "nip44_v2"], + nip44: ["nip44_v2", "nip44"], nip44_v2: ["nip44_v2", "nip44"], }; for (const hint of hints) { const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint]; for (const candidate of candidates) { + if (candidate === "nip04" && !allowNip04) { + continue; + } if (availableSet.has(candidate) && !prioritized.includes(candidate)) { prioritized.push(candidate); break; @@ -132,7 +138,11 @@ function determineDecryptionOrder(event, availableSchemes) { } } - for (const fallback of ["nip44_v2", "nip44", "nip04"]) { + const fallbacks = ["nip44_v2", "nip44"]; + if (allowNip04) { + fallbacks.push("nip04"); + } + for (const fallback of fallbacks) { if (availableSet.has(fallback) && !prioritized.includes(fallback)) { prioritized.push(fallback); } @@ -322,7 +332,8 @@ class TinyEventEmitter { /** * Manages the user's subscription list (kind=30000 follow set) *privately*, - * using encrypted NIP-51 tag arrays (NIP-04/NIP-44) for the content field. + * using encrypted NIP-51 tag arrays (prefer NIP-44 v2) for the content field. + * Published events should include an explicit ["encrypted", ""] tag. * Also handles fetching and rendering subscribed channels' videos * in the same card style as your home page. */ @@ -454,6 +465,8 @@ class SubscriptionsManager { const allowPermissionPrompt = options?.allowPermissionPrompt !== false; const wasBackgroundLoading = this.backgroundLoading; const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; + const wasLoadedForUser = + this.loaded && this.currentUserPubkey === normalizedUserPubkey; this.lastLoadError = null; const readRelays = relayManager.getReadRelayUrls(); @@ -487,6 +500,7 @@ class SubscriptionsManager { const cachedSnapshot = parseCachedSubscriptionSnapshot( profileCache.getProfileData(normalizedUserPubkey, "subscriptions"), ); + const hadCachedSnapshot = cachedSnapshot.hasSnapshot; const shouldForceFullFetch = !cachedSnapshot.hasSnapshot; let events = await nostrClient.fetchListIncrementally({ kind: SUBSCRIPTION_SET_KIND, @@ -632,6 +646,12 @@ class SubscriptionsManager { subscribedPubkeys: Array.from(this.subscribedPubkeys), }); } + if (wasLoadedForUser || hadCachedSnapshot) { + userLogger.warn( + "[SubscriptionsManager] Preserving cached subscriptions despite decryption failure.", + ); + return; + } if (!this.loaded) { this.subscribedPubkeys.clear(); this.subsEventId = null; @@ -1104,8 +1124,9 @@ class SubscriptionsManager { } /** - * Encrypt (NIP-04) + publish the updated subscription set + * Encrypt (prefer NIP-44 v2, fallback to NIP-04) + publish the updated subscription set * as kind=30000 with ["d", "subscriptions"] to be replaceable. + * The published event includes an ["encrypted", ""] tag. */ async publishSubscriptionList(userPubkey) { if (!userPubkey) {