Merge pull request #2058 from PR0M3TH3AN/codex/refactor-subscriptions-for-nip-44-preference

Prefer NIP-44 encryption hints for subscription lists
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-01 15:55:51 -05:00
commit 4147211a3f
3 files changed
+46 -9

No files matched your search

+7 -1
View File
@@ -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', <relay url>]` 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', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, 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}:<YYYY-MM>`]` with optional `['month', <YYYY-MM>]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: <eventId\|address>, watched_at?: <unix seconds> }] }` |
| 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', <hex>], …]`) |
| 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', <hex>], …]`) |
| 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', <pubkey>]` 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', '<scheme>']` 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', <mime>]` 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
+13 -3
View File
@@ -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,
+26 -5
View File
@@ -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", "<scheme>"] 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", "<scheme>"] tag.
*/
async publishSubscriptionList(userPubkey) {
if (!userPubkey) {