mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
fix(theme): preserve newer remote migration state
Cancel migration-only publishes before signing when newer complete relay state arrives, and reassert accepted state if submission already began. Parse later legacy updates against the latest scoped appearance instead of the startup fallback. Co-authored-by: Kenny Lopez <klopez4212@gmail.com> Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -188,7 +188,11 @@ export function CommunityThemeController() {
|
||||
);
|
||||
clearCommunityThemeMigrationOutbox(pubkey, relayUrl);
|
||||
},
|
||||
legacyFallback,
|
||||
() =>
|
||||
scopedPreferenceRef.current ??
|
||||
readCommunityThemeOutbox(pubkey, relayUrl, legacyFallback) ??
|
||||
readCommunityThemePreference(pubkey, relayUrl, legacyFallback) ??
|
||||
legacyFallback,
|
||||
);
|
||||
managerRef.current = manager;
|
||||
if (durablePending) {
|
||||
@@ -222,14 +226,22 @@ export function CommunityThemeController() {
|
||||
// coordinate cancels the pending upgrade rather than being overwritten by
|
||||
// a legacy-derived republish two seconds later.
|
||||
clearCommunityThemeMigrationOutbox(pubkey, relayUrl);
|
||||
scopedPreferenceRef.current = remote.preference;
|
||||
manager.cancelPendingPublish();
|
||||
const migrationWasSubmitted = manager.cancelPendingPublish(
|
||||
remote.preference,
|
||||
);
|
||||
cacheAndApplyCommunityTheme(
|
||||
pubkey,
|
||||
relayUrl,
|
||||
remote.preference,
|
||||
applyPreference,
|
||||
);
|
||||
scopedPreferenceRef.current = remote.preference;
|
||||
if (migrationWasSubmitted) {
|
||||
// Submission cannot be withdrawn. Reassert the accepted remote after
|
||||
// the stale migration settles so it remains the winning coordinate.
|
||||
writeCommunityThemeOutbox(pubkey, relayUrl, remote.preference);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
remote.needsUpgrade &&
|
||||
writeCommunityThemeMigrationOutbox(pubkey, relayUrl, remote.preference)
|
||||
|
||||
@@ -637,6 +637,172 @@ test("transient publish failure retries and acknowledges exact event", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("newer complete remote cancels an in-flight migration before signing", async () => {
|
||||
const timer = installFakeTimer();
|
||||
const encryption = Promise.withResolvers();
|
||||
const published = [];
|
||||
let signed = 0;
|
||||
globalThis.window.__TAURI_INTERNALS__ = {
|
||||
invoke(command, args) {
|
||||
if (command === "nip44_encrypt_to_self") return encryption.promise;
|
||||
if (command === "sign_event") {
|
||||
signed += 1;
|
||||
return Promise.resolve(
|
||||
JSON.stringify(
|
||||
relayEvent({
|
||||
id: `event-${signed}`,
|
||||
content: args.content,
|
||||
created_at: args.createdAt,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
},
|
||||
};
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
published.push(event);
|
||||
return Promise.resolve();
|
||||
});
|
||||
try {
|
||||
const manager = new CommunityThemeSyncManager("alice");
|
||||
manager.publish(preference);
|
||||
timer.fire();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
manager.acceptRemote({
|
||||
preference: { ...preference, theme: "dracula" },
|
||||
createdAt: 456,
|
||||
eventId: "remote-winner",
|
||||
needsUpgrade: false,
|
||||
});
|
||||
manager.cancelPendingPublish();
|
||||
encryption.resolve("cipher");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(signed, 0);
|
||||
assert.equal(published.length, 0);
|
||||
assert.equal(manager.getPending(), null);
|
||||
} finally {
|
||||
delete globalThis.window.__TAURI_INTERNALS__;
|
||||
timer.restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("remote accepted after submit is republished after the stale event settles", async () => {
|
||||
const timer = installFakeTimer();
|
||||
const firstPublish = Promise.withResolvers();
|
||||
const published = [];
|
||||
let signed = 0;
|
||||
globalThis.window.__TAURI_INTERNALS__ = {
|
||||
invoke(command, args) {
|
||||
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
|
||||
if (command === "sign_event") {
|
||||
signed += 1;
|
||||
return Promise.resolve(
|
||||
JSON.stringify(
|
||||
relayEvent({
|
||||
id: `event-${signed}`,
|
||||
content: args.content,
|
||||
created_at: args.createdAt,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
},
|
||||
};
|
||||
mock.method(relayClient, "publishEvent", (event) => {
|
||||
published.push(event);
|
||||
return published.length === 1 ? firstPublish.promise : Promise.resolve();
|
||||
});
|
||||
try {
|
||||
const manager = new CommunityThemeSyncManager("alice");
|
||||
manager.publish(preference);
|
||||
timer.fire();
|
||||
await waitUntil(() => published.length === 1);
|
||||
|
||||
const remotePreference = { ...preference, theme: "dracula" };
|
||||
manager.acceptRemote({
|
||||
preference: remotePreference,
|
||||
createdAt: published[0].created_at + 100,
|
||||
eventId: "remote-winner",
|
||||
needsUpgrade: false,
|
||||
});
|
||||
assert.equal(manager.cancelPendingPublish(remotePreference), true);
|
||||
firstPublish.resolve();
|
||||
await waitUntil(() => timer.pending());
|
||||
timer.fire();
|
||||
await waitUntil(() => published.length === 2);
|
||||
|
||||
assert.ok(published[1].created_at > published[0].created_at + 100);
|
||||
assert.deepEqual(manager.getPending(), null);
|
||||
} finally {
|
||||
delete globalThis.window.__TAURI_INTERNALS__;
|
||||
timer.restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("live legacy updates inherit the latest complete appearance", async () => {
|
||||
const initial = { ...preference, glassOpacity: 40 };
|
||||
const latest = {
|
||||
...preference,
|
||||
glassBackground: false,
|
||||
glassOpacity: 80,
|
||||
prominentActiveTab: true,
|
||||
};
|
||||
let fallback = initial;
|
||||
let liveCallback;
|
||||
const updates = [];
|
||||
globalThis.window.__TAURI_INTERNALS__ = {
|
||||
invoke(command) {
|
||||
if (command === "nip44_decrypt_from_self") {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
theme: "dracula",
|
||||
accent: preference.accent,
|
||||
followSystem: preference.followSystem,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
},
|
||||
};
|
||||
mock.method(relayClient, "subscribeLive", (_filter, callback) => {
|
||||
liveCallback = callback;
|
||||
return Promise.resolve(async () => {});
|
||||
});
|
||||
try {
|
||||
const manager = new CommunityThemeSyncManager(
|
||||
"alice",
|
||||
undefined,
|
||||
() => fallback,
|
||||
);
|
||||
await manager.subscribe((remote) => updates.push(remote));
|
||||
fallback = latest;
|
||||
liveCallback(
|
||||
relayEvent({
|
||||
id: "legacy-update",
|
||||
content: "ciphertext",
|
||||
created_at: 456,
|
||||
}),
|
||||
);
|
||||
await waitUntil(() => updates.length === 1);
|
||||
|
||||
assert.deepEqual(updates[0].preference, {
|
||||
...latest,
|
||||
theme: "dracula",
|
||||
});
|
||||
assert.equal(updates[0].needsUpgrade, true);
|
||||
} finally {
|
||||
delete globalThis.window.__TAURI_INTERNALS__;
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
async function waitUntil(condition) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (condition()) return;
|
||||
|
||||
@@ -65,12 +65,12 @@ export function shouldSeedCommunityTheme(
|
||||
|
||||
async function decryptAndParse(
|
||||
event: RelayEvent,
|
||||
legacyFallback: CommunityThemePreference,
|
||||
legacyFallback: () => CommunityThemePreference,
|
||||
): Promise<RemoteCommunityTheme | null> {
|
||||
try {
|
||||
const plaintext = await nip44DecryptFromSelf(event.content);
|
||||
const parsed = JSON.parse(plaintext);
|
||||
const preference = parseCommunityThemePreference(parsed, legacyFallback);
|
||||
const preference = parseCommunityThemePreference(parsed, legacyFallback());
|
||||
return preference
|
||||
? {
|
||||
preference,
|
||||
@@ -103,16 +103,24 @@ export class CommunityThemeSyncManager {
|
||||
private publishRetryAttempt = 0;
|
||||
private readonly remoteProcessing = new Set<Promise<unknown>>();
|
||||
private readonly onPublished: (published: PublishedCommunityTheme) => void;
|
||||
private readonly legacyFallback: CommunityThemePreference;
|
||||
private readonly getLegacyFallback: () => CommunityThemePreference;
|
||||
private publishGeneration = 0;
|
||||
private activePublishSubmitted = false;
|
||||
private replacementAfterCancel: CommunityThemePreference | null = null;
|
||||
|
||||
constructor(
|
||||
pubkey: string,
|
||||
onPublished: (published: PublishedCommunityTheme) => void = () => {},
|
||||
legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME,
|
||||
legacyFallback:
|
||||
| CommunityThemePreference
|
||||
| (() => CommunityThemePreference) = DEFAULT_COMMUNITY_THEME,
|
||||
) {
|
||||
this.pubkey = pubkey;
|
||||
this.onPublished = onPublished;
|
||||
this.legacyFallback = legacyFallback;
|
||||
this.getLegacyFallback =
|
||||
typeof legacyFallback === "function"
|
||||
? legacyFallback
|
||||
: () => legacyFallback;
|
||||
}
|
||||
|
||||
async fetchRemote(): Promise<RemoteCommunityThemeResult> {
|
||||
@@ -125,7 +133,7 @@ export class CommunityThemeSyncManager {
|
||||
});
|
||||
if (events.length === 0) return { status: "absent" };
|
||||
if (events[0].pubkey !== this.pubkey) return { status: "invalid" };
|
||||
const processing = decryptAndParse(events[0], this.legacyFallback);
|
||||
const processing = decryptAndParse(events[0], this.getLegacyFallback);
|
||||
this.trackRemoteProcessing(processing);
|
||||
const remote = await processing;
|
||||
if (!remote) return { status: "invalid" };
|
||||
@@ -166,9 +174,16 @@ export class CommunityThemeSyncManager {
|
||||
private startPublish(): void {
|
||||
if (this.destroyed || this.publishInFlight || !this.pending) return;
|
||||
this.publishInFlight = true;
|
||||
this.activePublishSubmitted = false;
|
||||
const preference = this.pending;
|
||||
void this.doPublish(preference).finally(() => {
|
||||
const generation = this.publishGeneration;
|
||||
void this.doPublish(preference, generation).finally(() => {
|
||||
this.publishInFlight = false;
|
||||
this.activePublishSubmitted = false;
|
||||
if (this.replacementAfterCancel) {
|
||||
this.pending = this.replacementAfterCancel;
|
||||
this.replacementAfterCancel = null;
|
||||
}
|
||||
if (
|
||||
!this.destroyed &&
|
||||
this.pending &&
|
||||
@@ -205,19 +220,31 @@ export class CommunityThemeSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
cancelPendingPublish(): void {
|
||||
cancelPendingPublish(
|
||||
replacementAfterSubmit?: CommunityThemePreference,
|
||||
): boolean {
|
||||
const submitted = this.activePublishSubmitted;
|
||||
this.replacementAfterCancel =
|
||||
submitted && replacementAfterSubmit ? replacementAfterSubmit : null;
|
||||
this.publishGeneration += 1;
|
||||
if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
this.pending = null;
|
||||
return submitted;
|
||||
}
|
||||
|
||||
private async doPublish(preference: CommunityThemePreference): Promise<void> {
|
||||
private async doPublish(
|
||||
preference: CommunityThemePreference,
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
const wasCancelled = () =>
|
||||
this.destroyed || generation !== this.publishGeneration;
|
||||
try {
|
||||
const lastPublished = this.lastPublished;
|
||||
if (
|
||||
this.destroyed ||
|
||||
wasCancelled() ||
|
||||
(lastPublished &&
|
||||
sameCommunityThemePreference(lastPublished.preference, preference))
|
||||
) {
|
||||
@@ -231,7 +258,7 @@ export class CommunityThemeSyncManager {
|
||||
return;
|
||||
}
|
||||
const ciphertext = await nip44EncryptToSelf(JSON.stringify(preference));
|
||||
if (this.destroyed) return;
|
||||
if (wasCancelled()) return;
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_COMMUNITY_THEME,
|
||||
content: ciphertext,
|
||||
@@ -244,14 +271,15 @@ export class CommunityThemeSyncManager {
|
||||
["t", D_TAG],
|
||||
],
|
||||
});
|
||||
if (this.destroyed) return;
|
||||
if (wasCancelled()) return;
|
||||
this.activePublishSubmitted = true;
|
||||
await relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out publishing community theme.",
|
||||
"Failed to publish community theme.",
|
||||
);
|
||||
await this.waitForDeliveredRemotes();
|
||||
if (this.destroyed) return;
|
||||
if (wasCancelled()) return;
|
||||
const published = {
|
||||
preference,
|
||||
createdAt: event.created_at,
|
||||
@@ -289,7 +317,7 @@ export class CommunityThemeSyncManager {
|
||||
} catch (error) {
|
||||
console.warn("[communityThemeSync] publish failed:", error);
|
||||
if (
|
||||
this.destroyed ||
|
||||
wasCancelled() ||
|
||||
!this.pending ||
|
||||
!sameCommunityThemePreference(this.pending, preference)
|
||||
) {
|
||||
@@ -374,7 +402,7 @@ export class CommunityThemeSyncManager {
|
||||
onUpdate: (remote: RemoteCommunityTheme) => void,
|
||||
): void {
|
||||
if (event.pubkey !== this.pubkey || this.destroyed) return;
|
||||
const processing = decryptAndParse(event, this.legacyFallback).then(
|
||||
const processing = decryptAndParse(event, this.getLegacyFallback).then(
|
||||
(remote) => {
|
||||
if (this.destroyed) return;
|
||||
if (!remote) {
|
||||
|
||||
Reference in New Issue
Block a user