mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
fix(sync): emit settled list state for hashtags and blocks with regression tests
This commit is contained in:
1 parent
c23dcc6c46
commit
3dbacd20cc
4 files changed
+366
-7
No files matched your search
@@ -526,6 +526,13 @@ class HashtagPreferencesService {
|
||||
eventId: this.eventId,
|
||||
createdAt: this.eventCreatedAt,
|
||||
version: this.preferencesVersion,
|
||||
loaded: this.loaded === true,
|
||||
uiReady: this.uiReady === true,
|
||||
dataReady: this.dataReady === true,
|
||||
loadedFromCache: this.loadedFromCache === true,
|
||||
backgroundLoading: this.backgroundLoading === true,
|
||||
lastLoadError: this.lastLoadError || null,
|
||||
activePubkey: this.activePubkey || null,
|
||||
...detail,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -589,6 +596,11 @@ class HashtagPreferencesService {
|
||||
state: this.getLoadState(),
|
||||
};
|
||||
} finally {
|
||||
this.emitChange("load-settled", {
|
||||
...this.getLoadState(),
|
||||
loaded: this.loaded === true,
|
||||
backgroundLoading: this.backgroundLoading === true,
|
||||
});
|
||||
if (this.loadPromise === loadPromise) {
|
||||
this.loadPromise = null;
|
||||
this.loadingPubkey = null;
|
||||
|
||||
+7
-7
@@ -705,6 +705,7 @@ class UserBlockListManager {
|
||||
this.blockedPubkeys = new Set();
|
||||
this._privateBlocks = new Set();
|
||||
this._publicMutes = new Set();
|
||||
this.activePubkey = null;
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
this.lastPublishedCreatedAt = null;
|
||||
@@ -718,7 +719,6 @@ class UserBlockListManager {
|
||||
this.loadPromise = null;
|
||||
this.loadingPubkey = null;
|
||||
this.blockListSubscriptionKey = null;
|
||||
|
||||
profileCache.subscribe((event, detail) => {
|
||||
if (event === "profileChanged") {
|
||||
this.reset();
|
||||
@@ -793,7 +793,6 @@ class UserBlockListManager {
|
||||
if (!isUserBlockListEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadBlocks(normalized).catch((error) => {
|
||||
devLogger.warn("[UserBlockList] Failed to refresh after block list event", error);
|
||||
});
|
||||
@@ -864,7 +863,6 @@ class UserBlockListManager {
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadBlocks(normalized);
|
||||
}
|
||||
|
||||
@@ -877,7 +875,6 @@ class UserBlockListManager {
|
||||
});
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
if (this.loadPromise && this.loadingPubkey && this.loadingPubkey !== normalized) {
|
||||
devLogger.log("[UserBlockList] Waiting for existing block list load to settle.", {
|
||||
previous: this.loadingPubkey,
|
||||
@@ -889,10 +886,15 @@ class UserBlockListManager {
|
||||
const loadPromise = this.loadBlocksInternal(normalized, options);
|
||||
this.loadPromise = loadPromise;
|
||||
this.loadingPubkey = normalized;
|
||||
|
||||
try {
|
||||
return await loadPromise;
|
||||
} finally {
|
||||
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
||||
action: "load-settled",
|
||||
blockedPubkeys: Array.from(this.blockedPubkeys),
|
||||
loaded: this.loaded === true,
|
||||
activePubkey: this.activePubkey || null,
|
||||
});
|
||||
if (this.loadPromise === loadPromise) {
|
||||
this.loadPromise = null;
|
||||
this.loadingPubkey = null;
|
||||
@@ -931,7 +933,6 @@ class UserBlockListManager {
|
||||
// realistic window while still failing faster than the full 15s interactive
|
||||
// timeout.
|
||||
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000;
|
||||
|
||||
const emitStatus = (detail) => {
|
||||
if (!detail || typeof detail !== "object") {
|
||||
return;
|
||||
@@ -997,7 +998,6 @@ class UserBlockListManager {
|
||||
this._privateBlocks = privateSet;
|
||||
this._publicMutes = publicSet;
|
||||
this.blockedPubkeys = new Set([...privateSet, ...publicSet]);
|
||||
|
||||
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
||||
action: "sync",
|
||||
blockedPubkeys: Array.from(this.blockedPubkeys),
|
||||
|
||||
@@ -323,6 +323,183 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"load emits load-settled with readiness metadata for empty relay results",
|
||||
{ concurrency: false },
|
||||
async () => {
|
||||
const pubkey = "1".repeat(64);
|
||||
const changes = [];
|
||||
const unsubscribe = hashtagPreferences.on("change", (detail) => {
|
||||
changes.push(detail);
|
||||
});
|
||||
|
||||
const originalFetchIncremental = nostrClient.fetchListIncrementally;
|
||||
try {
|
||||
relayManager.setEntries(
|
||||
[{ url: "wss://relay.empty", mode: "both" }],
|
||||
{ allowEmpty: false, updateClient: false },
|
||||
);
|
||||
nostrClient.fetchListIncrementally = async () => [];
|
||||
|
||||
await hashtagPreferences.load(pubkey);
|
||||
|
||||
const settled = changes.filter((entry) => entry?.action === "load-settled");
|
||||
assert.ok(settled.length >= 1, "load should emit a final load-settled event");
|
||||
|
||||
const final = settled[settled.length - 1];
|
||||
assert.equal(final.activePubkey, pubkey);
|
||||
assert.equal(final.uiReady, true);
|
||||
assert.equal(final.dataReady, true);
|
||||
assert.equal(final.loaded, true);
|
||||
assert.equal(final.loadedFromCache, false);
|
||||
assert.equal(final.lastLoadError, null);
|
||||
assert.deepEqual(final.interests, []);
|
||||
assert.deepEqual(final.disinterests, []);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
nostrClient.fetchListIncrementally = originalFetchIncremental;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"load emits load-settled with permission-required metadata when decrypt is deferred",
|
||||
{ concurrency: false },
|
||||
async () => {
|
||||
const pubkey = "2".repeat(64);
|
||||
const changes = [];
|
||||
const unsubscribe = hashtagPreferences.on("change", (detail) => {
|
||||
changes.push(detail);
|
||||
});
|
||||
|
||||
const originalFetchIncremental = nostrClient.fetchListIncrementally;
|
||||
const originalWindowNostr = window.nostr;
|
||||
const relayUrls = ["wss://relay.permission-metadata"];
|
||||
|
||||
try {
|
||||
relayManager.setEntries(
|
||||
relayUrls.map((url) => ({ url, mode: "both" })),
|
||||
{ allowEmpty: false, updateClient: false },
|
||||
);
|
||||
nostrClient.relays = relayUrls;
|
||||
nostrClient.writeRelays = relayUrls;
|
||||
nostrClient.fetchListIncrementally = async () => [
|
||||
{
|
||||
id: "pref-permission-metadata",
|
||||
created_at: 401,
|
||||
pubkey,
|
||||
content: "cipher-permission-metadata",
|
||||
tags: [["encrypted", "nip04"]],
|
||||
},
|
||||
];
|
||||
|
||||
window.nostr = {
|
||||
enable: async () => {},
|
||||
getPublicKey: async () => pubkey,
|
||||
nip04: {
|
||||
decrypt: async () => {
|
||||
throw new Error("permission denied");
|
||||
},
|
||||
},
|
||||
};
|
||||
clearActiveSigner();
|
||||
|
||||
await hashtagPreferences.load(pubkey, { allowPermissionPrompt: false });
|
||||
|
||||
const settled = changes.filter((entry) => entry?.action === "load-settled");
|
||||
assert.ok(settled.length >= 1, "load should emit load-settled even on deferred decrypt");
|
||||
|
||||
const final = settled[settled.length - 1];
|
||||
assert.equal(final.activePubkey, pubkey);
|
||||
assert.equal(final.uiReady, true);
|
||||
assert.equal(typeof final.dataReady, "boolean");
|
||||
assert.equal(final.dataReady, hashtagPreferences.dataReady === true);
|
||||
assert.equal(typeof final.loaded, "boolean");
|
||||
assert.equal(final.loaded, hashtagPreferences.loaded === true);
|
||||
assert.equal(final.lastLoadError?.code, "hashtag-preferences-permission-required");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
nostrClient.fetchListIncrementally = originalFetchIncremental;
|
||||
window.nostr = originalWindowNostr;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"load emits load-settled with loadedFromCache when relay fetch is unavailable",
|
||||
{ concurrency: false },
|
||||
async () => {
|
||||
const pubkey = "3".repeat(64);
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalLoadFromCache = hashtagPreferences.loadFromCache;
|
||||
const originalInterests = hashtagPreferences.interests;
|
||||
const originalDisinterests = hashtagPreferences.disinterests;
|
||||
const originalEventId = hashtagPreferences.eventId;
|
||||
const originalEventCreatedAt = hashtagPreferences.eventCreatedAt;
|
||||
const originalLoaded = hashtagPreferences.loaded;
|
||||
const originalUiReady = hashtagPreferences.uiReady;
|
||||
const originalDataReady = hashtagPreferences.dataReady;
|
||||
const originalLoadedFromCache = hashtagPreferences.loadedFromCache;
|
||||
const originalLastSuccessfulSyncAt = hashtagPreferences.lastSuccessfulSyncAt;
|
||||
const originalPreferencesVersion = hashtagPreferences.preferencesVersion;
|
||||
|
||||
try {
|
||||
hashtagPreferences.loadFromCache = (candidate) => {
|
||||
if (candidate !== pubkey) {
|
||||
return false;
|
||||
}
|
||||
hashtagPreferences.activePubkey = candidate;
|
||||
hashtagPreferences.interests = new Set(["cachepref"]);
|
||||
hashtagPreferences.disinterests = new Set();
|
||||
hashtagPreferences.eventId = "cache-event";
|
||||
hashtagPreferences.eventCreatedAt = 777;
|
||||
hashtagPreferences.loaded = true;
|
||||
hashtagPreferences.uiReady = true;
|
||||
hashtagPreferences.dataReady = true;
|
||||
hashtagPreferences.loadedFromCache = true;
|
||||
hashtagPreferences.lastSuccessfulSyncAt = 777000;
|
||||
hashtagPreferences.preferencesVersion = 1;
|
||||
hashtagPreferences.emitChange("cache-load");
|
||||
return true;
|
||||
};
|
||||
|
||||
const changes = [];
|
||||
const unsubscribe = hashtagPreferences.on("change", (detail) => {
|
||||
changes.push(detail);
|
||||
});
|
||||
try {
|
||||
nostrClient.pool = null;
|
||||
await hashtagPreferences.load(pubkey, { allowPermissionPrompt: false });
|
||||
|
||||
const settled = changes.filter((entry) => entry?.action === "load-settled");
|
||||
assert.ok(settled.length >= 1, "cache-backed load should emit load-settled");
|
||||
const final = settled[settled.length - 1];
|
||||
|
||||
assert.equal(final.activePubkey, pubkey);
|
||||
assert.equal(final.loadedFromCache, true);
|
||||
assert.equal(final.dataReady, true);
|
||||
assert.equal(final.uiReady, true);
|
||||
assert.deepEqual(final.interests, ["cachepref"]);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
} finally {
|
||||
nostrClient.pool = originalPool;
|
||||
hashtagPreferences.loadFromCache = originalLoadFromCache;
|
||||
hashtagPreferences.interests = originalInterests;
|
||||
hashtagPreferences.disinterests = originalDisinterests;
|
||||
hashtagPreferences.eventId = originalEventId;
|
||||
hashtagPreferences.eventCreatedAt = originalEventCreatedAt;
|
||||
hashtagPreferences.loaded = originalLoaded;
|
||||
hashtagPreferences.uiReady = originalUiReady;
|
||||
hashtagPreferences.dataReady = originalDataReady;
|
||||
hashtagPreferences.loadedFromCache = originalLoadedFromCache;
|
||||
hashtagPreferences.lastSuccessfulSyncAt = originalLastSuccessfulSyncAt;
|
||||
hashtagPreferences.preferencesVersion = originalPreferencesVersion;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"interest and disinterest lists remain exclusive",
|
||||
{ concurrency: false },
|
||||
|
||||
@@ -166,6 +166,176 @@ await (async () => {
|
||||
}
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
const actor = "7".repeat(64);
|
||||
const UserBlockListManager = userBlocks.constructor;
|
||||
const manager = new UserBlockListManager();
|
||||
|
||||
const originalRelayEntries = relayManager.getEntries();
|
||||
const originalFetchIncremental = nostrClient.fetchListIncrementally;
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: nostrClient.relays;
|
||||
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
||||
? [...nostrClient.writeRelays]
|
||||
: nostrClient.writeRelays;
|
||||
|
||||
const relayUrls = ["wss://relay-blocks-empty.example"];
|
||||
relayManager.setEntries(
|
||||
relayUrls.map((url) => ({ url, mode: "both" })),
|
||||
{ allowEmpty: false, updateClient: false },
|
||||
);
|
||||
nostrClient.relays = relayUrls;
|
||||
nostrClient.writeRelays = relayUrls;
|
||||
nostrClient.pool = originalPool || { list: async () => [] };
|
||||
nostrClient.fetchListIncrementally = async () => [];
|
||||
|
||||
const changeEvents = [];
|
||||
const statusEvents = [];
|
||||
const unsubscribeChange = manager.on(USER_BLOCK_EVENTS.CHANGE, (detail) => {
|
||||
changeEvents.push(detail);
|
||||
});
|
||||
const unsubscribeStatus = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
|
||||
statusEvents.push(detail);
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.loadBlocks(actor);
|
||||
|
||||
const settledChange = changeEvents.filter(
|
||||
(detail) => detail?.action === "load-settled",
|
||||
);
|
||||
assert.ok(
|
||||
settledChange.length >= 1,
|
||||
"loadBlocks should emit a final load-settled change event",
|
||||
);
|
||||
const finalChange = settledChange[settledChange.length - 1];
|
||||
assert.equal(finalChange.activePubkey, actor);
|
||||
assert.equal(finalChange.loaded, true);
|
||||
assert.deepEqual(finalChange.blockedPubkeys, []);
|
||||
|
||||
const settledStatus = statusEvents.filter(
|
||||
(detail) => detail?.status === "settled",
|
||||
);
|
||||
assert.ok(
|
||||
settledStatus.length >= 1,
|
||||
"loadBlocks should emit settled status metadata",
|
||||
);
|
||||
const finalStatus = settledStatus[settledStatus.length - 1];
|
||||
assert.equal(finalStatus.status, "settled");
|
||||
} finally {
|
||||
unsubscribeChange?.();
|
||||
unsubscribeStatus?.();
|
||||
relayManager.setEntries(
|
||||
originalRelayEntries,
|
||||
{ allowEmpty: false, updateClient: false },
|
||||
);
|
||||
nostrClient.fetchListIncrementally = originalFetchIncremental;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.writeRelays = originalWriteRelays;
|
||||
manager.reset();
|
||||
}
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
const actor = "8".repeat(64);
|
||||
const UserBlockListManager = userBlocks.constructor;
|
||||
const manager = new UserBlockListManager();
|
||||
|
||||
const originalRelayEntries = relayManager.getEntries();
|
||||
const originalFetchIncremental = nostrClient.fetchListIncrementally;
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: nostrClient.relays;
|
||||
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
||||
? [...nostrClient.writeRelays]
|
||||
: nostrClient.writeRelays;
|
||||
const originalNostr = window.nostr;
|
||||
const originalEnsureSignerForPubkey = nostrClient.ensureActiveSignerForPubkey;
|
||||
const originalSigner = getActiveSigner();
|
||||
|
||||
const relayUrls = ["wss://relay-blocks-permission.example"];
|
||||
relayManager.setEntries(
|
||||
relayUrls.map((url) => ({ url, mode: "both" })),
|
||||
{ allowEmpty: false, updateClient: false },
|
||||
);
|
||||
nostrClient.relays = relayUrls;
|
||||
nostrClient.writeRelays = relayUrls;
|
||||
nostrClient.pool = originalPool || { list: async () => [] };
|
||||
nostrClient.fetchListIncrementally = async () => [
|
||||
{
|
||||
id: "event-blocks-permission",
|
||||
kind: 10000,
|
||||
created_at: 505,
|
||||
pubkey: actor,
|
||||
content: "cipher-blocks-permission",
|
||||
tags: [["encrypted", "nip04"]],
|
||||
},
|
||||
];
|
||||
nostrClient.ensureActiveSignerForPubkey = async () => null;
|
||||
clearActiveSigner();
|
||||
window.nostr = undefined;
|
||||
|
||||
const changeEvents = [];
|
||||
const statusEvents = [];
|
||||
const unsubscribeChange = manager.on(USER_BLOCK_EVENTS.CHANGE, (detail) => {
|
||||
changeEvents.push(detail);
|
||||
});
|
||||
const unsubscribeStatus = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
|
||||
statusEvents.push(detail);
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.loadBlocks(actor, { allowPermissionPrompt: false });
|
||||
|
||||
const settledChange = changeEvents.filter(
|
||||
(detail) => detail?.action === "load-settled",
|
||||
);
|
||||
assert.ok(
|
||||
settledChange.length >= 1,
|
||||
"permission-gated loads should still emit load-settled change",
|
||||
);
|
||||
const finalChange = settledChange[settledChange.length - 1];
|
||||
assert.equal(finalChange.activePubkey, actor);
|
||||
assert.equal(finalChange.loaded, true);
|
||||
|
||||
const settledStatus = statusEvents.filter(
|
||||
(detail) => detail?.status === "settled",
|
||||
);
|
||||
assert.ok(
|
||||
settledStatus.length >= 1,
|
||||
"permission-gated loads should still emit settled status",
|
||||
);
|
||||
assert.ok(
|
||||
statusEvents.some(
|
||||
(detail) => detail?.status === "permission-required",
|
||||
),
|
||||
"permission-gated load should emit permission-required status before settling",
|
||||
);
|
||||
} finally {
|
||||
unsubscribeChange?.();
|
||||
unsubscribeStatus?.();
|
||||
relayManager.setEntries(
|
||||
originalRelayEntries,
|
||||
{ allowEmpty: false, updateClient: false },
|
||||
);
|
||||
nostrClient.fetchListIncrementally = originalFetchIncremental;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.writeRelays = originalWriteRelays;
|
||||
nostrClient.ensureActiveSignerForPubkey = originalEnsureSignerForPubkey;
|
||||
window.nostr = originalNostr;
|
||||
clearActiveSigner();
|
||||
if (originalSigner) {
|
||||
setActiveSigner(originalSigner);
|
||||
}
|
||||
manager.reset();
|
||||
}
|
||||
})();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
await (async () => {
|
||||
|
||||
Reference in new issue
Block a user