mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): preserve theme when opening communities (#5266)
**Category:** fix **User Impact:** Previously selected community themes are preserved when opening a relay through onboarding, while first-time theme migration still completes for communities with no saved theme. **Problem:** During community initialization, desktop queried theme history before establishing live delivery. If a replacement event arrived while an empty history query was in flight, the client could incorrectly treat the theme as absent and publish the default over the user's saved selection. **Solution:** Subscribe before fetching history, expose whether live readiness reached EOSE, flush buffered live events before resolving EOSE, and retain the newest delivered replacement through hydration. Seed the inherited/default theme only when both live and history snapshots reach EOSE with no valid or unreadable event; subscription failures, CLOSED, readiness timeout, relay failure, and unreadable events fail closed without publishing. <details> <summary>File changes</summary> **desktop/src/shared/api/relayClientSession.ts / relayClientShared.ts / relayClosedRecovery.ts** Distinguish EOSE from CLOSED/timeout readiness and flush buffered events before resolving an EOSE fence. **desktop/src/shared/theme/CommunityThemeController.tsx** Seed and complete first-community migration only for confirmed absence; uncertain hydration remains non-publishing. **desktop/src/shared/theme/communityThemePreference.ts** Keep the inherited appearance for the first migrated community and the stable default for later empty communities. **desktop/src/shared/theme/communityThemeSync.ts** Arbitrate live and history results into valid, confirmed-absent, invalid, or unavailable hydration outcomes. **Tests** Cover EOSE/CLOSED readiness, subscription failure, timeout, unreadable and live-racing events, no-op initialization, and first-to-later community fallback isolation. </details> ## Reproduction steps 1. Save a non-default appearance for a community relay. 2. Remove the community locally, then open the same relay again through onboarding. 3. Arrange for the saved replacement event to arrive live while the initial history query returns empty. 4. Confirm the saved appearance remains selected and the client does not publish the default theme over it. 5. On an account with no theme records, open a first empty community and confirm its inherited appearance is migrated; open a later empty community and confirm it starts from the stable default. ## Validation - Pre-push desktop checks, typecheck, and full desktop tests: passed at `f79556b0e` - Focused theme/relay readiness tests: 38 passed - Desktop file-size ratchet and diff check: passed --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
getTextPayload,
|
||||
type ConnectionState,
|
||||
type LiveSubscriptionReadiness,
|
||||
type PendingEvent,
|
||||
type RelaySubscription,
|
||||
type RelaySubscriptionFilter,
|
||||
@@ -412,10 +413,10 @@ export class RelayClient {
|
||||
async subscribeLive(
|
||||
filter: RelaySubscriptionFilter,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
onReady?: (readiness: LiveSubscriptionReadiness) => void,
|
||||
) {
|
||||
return this.subscribe(filter, onEvent);
|
||||
return this.subscribe(filter, onEvent, onReady);
|
||||
}
|
||||
|
||||
async subscribeToChannelMentionEvents(
|
||||
channelId: string,
|
||||
pubkey: string,
|
||||
@@ -426,7 +427,6 @@ export class RelayClient {
|
||||
onEvent,
|
||||
);
|
||||
}
|
||||
|
||||
async preconnect() {
|
||||
// Explicit re-engagement (reconnect card / community switch): clears the
|
||||
// terminal latch and AUTH rejection streak, and bypasses backoff once.
|
||||
@@ -599,22 +599,23 @@ export class RelayClient {
|
||||
private async subscribe(
|
||||
filter: RelaySubscriptionFilter,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
onReady?: (readiness: LiveSubscriptionReadiness) => void,
|
||||
) {
|
||||
await this.ensureConnected();
|
||||
|
||||
const subId = `live-${crypto.randomUUID()}`;
|
||||
let resolveReady = () => {
|
||||
return;
|
||||
};
|
||||
let resolveReady = (_readiness: LiveSubscriptionReadiness) => {};
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
resolveReady = () => {
|
||||
resolveReady = (readiness) => {
|
||||
window.clearTimeout(fallbackTimeout);
|
||||
onReady?.(readiness);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
const fallbackTimeout = window.setTimeout(() => {
|
||||
resolveReady();
|
||||
}, 250);
|
||||
const fallbackTimeout = window.setTimeout(
|
||||
() => resolveReady("timeout"),
|
||||
250,
|
||||
);
|
||||
|
||||
this.subscriptions.set(subId, {
|
||||
mode: "live",
|
||||
@@ -887,6 +888,7 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
private handleEose(subId: string) {
|
||||
this.flushEventBuffer(); // Deliver preceding EVENT frames before EOSE.
|
||||
handleSubscriptionEose({
|
||||
subscriptions: this.subscriptions,
|
||||
subId,
|
||||
@@ -1068,18 +1070,15 @@ export class RelayClient {
|
||||
this.subscriptions.delete(subId);
|
||||
continue;
|
||||
}
|
||||
|
||||
subscription.resolveReady?.();
|
||||
subscription.resolveReady?.("closed");
|
||||
subscription.resolveReady = undefined;
|
||||
clearClosedRetry(subscription);
|
||||
}
|
||||
|
||||
for (const [eventId, pendingEvent] of this.pendingEvents) {
|
||||
window.clearTimeout(pendingEvent.timeout);
|
||||
pendingEvent.reject(error);
|
||||
this.pendingEvents.delete(eventId);
|
||||
}
|
||||
|
||||
if (options?.reconnect !== false) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
@@ -54,11 +54,13 @@ type FirstEventSubscription = {
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout";
|
||||
|
||||
type LiveSubscription = {
|
||||
mode: "live";
|
||||
filter: RelaySubscriptionFilter;
|
||||
onEvent: (event: RelayEvent) => void;
|
||||
resolveReady?: () => void;
|
||||
resolveReady?: (readiness: LiveSubscriptionReadiness) => void;
|
||||
lastSeenCreatedAt?: number;
|
||||
/**
|
||||
* Lower bound of a reconnect backfill window that has not yet completed.
|
||||
|
||||
@@ -260,6 +260,44 @@ test("first-event request resolves null when EOSE arrives without an event", asy
|
||||
assert.equal(subscriptions.has(requestedSubId), false);
|
||||
});
|
||||
|
||||
test("live readiness distinguishes EOSE from CLOSED", () => {
|
||||
const readiness = [];
|
||||
const subscriptions = new Map([
|
||||
[
|
||||
"live-eose",
|
||||
{
|
||||
mode: "live",
|
||||
filter: { kinds: [9], limit: 0 },
|
||||
onEvent: () => {},
|
||||
resolveReady: (result) => readiness.push(result),
|
||||
},
|
||||
],
|
||||
[
|
||||
"live-closed",
|
||||
{
|
||||
mode: "live",
|
||||
filter: { kinds: [9], limit: 0 },
|
||||
onEvent: () => {},
|
||||
resolveReady: (result) => readiness.push(result),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
handleSubscriptionEose({
|
||||
subscriptions,
|
||||
subId: "live-eose",
|
||||
closeSubscription: async () => {},
|
||||
});
|
||||
handleRelayClosed({
|
||||
subscriptions,
|
||||
subId: "live-closed",
|
||||
message: "restricted: access revoked",
|
||||
sendReq: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
assert.deepEqual(readiness, ["eose", "closed"]);
|
||||
});
|
||||
|
||||
test("production CLOSED handler removes terminal live subscriptions", () => {
|
||||
let readyCalls = 0;
|
||||
const subscriptions = new Map([
|
||||
|
||||
@@ -73,7 +73,7 @@ function recoverLiveSubscriptionFromClosed({
|
||||
message: string;
|
||||
sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise<void>;
|
||||
}) {
|
||||
subscription.resolveReady?.();
|
||||
subscription.resolveReady?.("closed");
|
||||
subscription.resolveReady = undefined;
|
||||
|
||||
const closedClass = classifyRelayClosed(message);
|
||||
@@ -157,7 +157,7 @@ export function handleSubscriptionEose({
|
||||
const subscription = subscriptions.get(subId);
|
||||
if (!subscription) return;
|
||||
if (subscription.mode === "live") {
|
||||
subscription.resolveReady?.();
|
||||
subscription.resolveReady?.("eose");
|
||||
subscription.resolveReady = undefined;
|
||||
subscription.closedRetryAttempt = 0;
|
||||
clearClosedRetry(subscription);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
clearCommunityThemeOutbox,
|
||||
communityThemeApplyExpectation,
|
||||
communityThemePersistenceAction,
|
||||
communityThemeScopeFallback,
|
||||
hasMigratedCommunityTheme,
|
||||
markCommunityThemeMigrated,
|
||||
readCommunityThemeOutbox,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "./communityThemePreference";
|
||||
import {
|
||||
CommunityThemeSyncManager,
|
||||
communityThemeHydrationRemote,
|
||||
isNewerCommunityThemeCoordinate,
|
||||
shouldSeedCommunityTheme,
|
||||
type RemoteCommunityTheme,
|
||||
@@ -74,12 +76,21 @@ export function CommunityThemeController() {
|
||||
// Preserve the user's existing global appearance the first time this
|
||||
// feature sees their current community. Later missing/malformed target
|
||||
// records use the stable default so the previous community never leaks.
|
||||
const fallback = hasMigratedCommunityTheme(pubkey)
|
||||
? DEFAULT_COMMUNITY_THEME
|
||||
: initialPreferenceRef.current;
|
||||
const fallback = communityThemeScopeFallback(
|
||||
hasMigratedCommunityTheme(pubkey),
|
||||
initialPreferenceRef.current,
|
||||
);
|
||||
const scopedPreference = dirty ?? local ?? fallback;
|
||||
scopedPreferenceRef.current = scopedPreference;
|
||||
applyPreference(scopedPreference);
|
||||
// Initialization is programmatic even when the provider already exposes
|
||||
// this exact value. Mark it after applyPreference so its no-op optimization
|
||||
// cannot make the persistence effect mistake the fallback for a user edit.
|
||||
expectedAppliedRef.current = communityThemeApplyExpectation(
|
||||
scopedPreference,
|
||||
currentPreferenceRef.current,
|
||||
true,
|
||||
);
|
||||
}, [pubkey, relayUrl, applyPreference]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,31 +138,33 @@ export function CommunityThemeController() {
|
||||
);
|
||||
};
|
||||
|
||||
void manager.fetchRemote().then((result) => {
|
||||
if (scopeRef.current !== scope) return;
|
||||
if (result.status === "valid") {
|
||||
applyRemote(result.remote);
|
||||
markCommunityThemeMigrated(pubkey);
|
||||
} else if (shouldSeedCommunityTheme(result)) {
|
||||
const local =
|
||||
readCommunityThemeOutbox(pubkey, relayUrl) ??
|
||||
readCommunityThemePreference(pubkey, relayUrl) ??
|
||||
scopedPreferenceRef.current ??
|
||||
DEFAULT_COMMUNITY_THEME;
|
||||
writeCommunityThemePreference(pubkey, relayUrl, local);
|
||||
writeCommunityThemeOutbox(pubkey, relayUrl, local);
|
||||
markCommunityThemeMigrated(pubkey);
|
||||
manager.publish(local);
|
||||
}
|
||||
// Invalid/future or unavailable records use the already-applied local
|
||||
// fallback without publishing over relay state we cannot safely read.
|
||||
});
|
||||
|
||||
let unsubscribe: (() => Promise<void>) | null = null;
|
||||
void manager.subscribe(applyRemote).then((dispose) => {
|
||||
if (scopeRef.current !== scope) void dispose();
|
||||
else unsubscribe = dispose;
|
||||
});
|
||||
void manager
|
||||
.subscribeAndFetch(applyRemote)
|
||||
.then(({ result, unsubscribe: dispose }) => {
|
||||
if (scopeRef.current !== scope) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
unsubscribe = dispose;
|
||||
const remote = communityThemeHydrationRemote(result);
|
||||
if (remote) {
|
||||
applyRemote(remote);
|
||||
markCommunityThemeMigrated(pubkey);
|
||||
} else if (shouldSeedCommunityTheme(result)) {
|
||||
const local =
|
||||
readCommunityThemeOutbox(pubkey, relayUrl) ??
|
||||
readCommunityThemePreference(pubkey, relayUrl) ??
|
||||
scopedPreferenceRef.current ??
|
||||
DEFAULT_COMMUNITY_THEME;
|
||||
writeCommunityThemePreference(pubkey, relayUrl, local);
|
||||
writeCommunityThemeOutbox(pubkey, relayUrl, local);
|
||||
markCommunityThemeMigrated(pubkey);
|
||||
manager.publish(local);
|
||||
}
|
||||
// Invalid or unavailable hydration keeps the already-applied fallback
|
||||
// without publishing over relay state we could not establish safely.
|
||||
});
|
||||
const unsubscribeReconnect = relayClient.subscribeToReconnects(() => {
|
||||
void manager.fetchRemote().then((result) => {
|
||||
if (result.status === "valid") {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
communityThemeApplyExpectation,
|
||||
communityThemeOutboxKey,
|
||||
communityThemePersistenceAction,
|
||||
communityThemeScopeFallback,
|
||||
communityThemeStorageKey,
|
||||
parseCommunityThemePreference,
|
||||
readCommunityThemeOutbox,
|
||||
@@ -163,6 +164,33 @@ test("already-applied relay state leaves the next user edit publishable", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("no-op initialization remains programmatic", () => {
|
||||
const expectation = communityThemeApplyExpectation(
|
||||
DEFAULT_COMMUNITY_THEME,
|
||||
DEFAULT_COMMUNITY_THEME,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
communityThemePersistenceAction(expectation, DEFAULT_COMMUNITY_THEME),
|
||||
"acknowledge",
|
||||
);
|
||||
});
|
||||
|
||||
test("confirmed first-community migration isolates later empty scopes", () => {
|
||||
const inherited = {
|
||||
...DEFAULT_COMMUNITY_THEME,
|
||||
theme: "dracula",
|
||||
followSystem: false,
|
||||
};
|
||||
|
||||
assert.deepEqual(communityThemeScopeFallback(false, inherited), inherited);
|
||||
assert.deepEqual(
|
||||
communityThemeScopeFallback(true, inherited),
|
||||
DEFAULT_COMMUNITY_THEME,
|
||||
);
|
||||
});
|
||||
|
||||
test("community switch defers stale outgoing appearance persistence", () => {
|
||||
const outgoing = {
|
||||
...DEFAULT_COMMUNITY_THEME,
|
||||
|
||||
@@ -165,6 +165,13 @@ export function cacheAndApplyCommunityTheme(
|
||||
apply(preference);
|
||||
}
|
||||
|
||||
export function communityThemeScopeFallback(
|
||||
migrated: boolean,
|
||||
inherited: CommunityThemePreference,
|
||||
): CommunityThemePreference {
|
||||
return migrated ? DEFAULT_COMMUNITY_THEME : inherited;
|
||||
}
|
||||
|
||||
export function sameCommunityThemePreference(
|
||||
left: CommunityThemePreference,
|
||||
right: CommunityThemePreference,
|
||||
@@ -179,8 +186,11 @@ export function sameCommunityThemePreference(
|
||||
export function communityThemeApplyExpectation(
|
||||
preference: CommunityThemePreference,
|
||||
current: CommunityThemePreference,
|
||||
preserveNoop = false,
|
||||
): CommunityThemePreference | null {
|
||||
return sameCommunityThemePreference(preference, current) ? null : preference;
|
||||
return preserveNoop || !sameCommunityThemePreference(preference, current)
|
||||
? preference
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import test, { mock } from "node:test";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
CommunityThemeSyncManager,
|
||||
communityThemeHydrationRemote,
|
||||
isNewerCommunityThemeCoordinate,
|
||||
shouldSeedCommunityTheme,
|
||||
} from "./communityThemeSync.ts";
|
||||
@@ -104,6 +105,147 @@ test("fetch distinguishes absent remote state from unreadable existing state", a
|
||||
}
|
||||
});
|
||||
|
||||
test("live replacement delivered during empty onboarding fetch prevents default seeding", async () => {
|
||||
const remotePreference = { ...preference, theme: "dracula" };
|
||||
const updates = [];
|
||||
let liveCallback;
|
||||
globalThis.window ??= {};
|
||||
globalThis.window.__TAURI_INTERNALS__ = {
|
||||
invoke(command) {
|
||||
if (command === "nip44_decrypt_from_self") {
|
||||
return Promise.resolve(JSON.stringify(remotePreference));
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
},
|
||||
};
|
||||
mock.method(relayClient, "subscribeLive", (_filter, callback, onReady) => {
|
||||
liveCallback = callback;
|
||||
onReady("eose");
|
||||
return Promise.resolve(async () => {});
|
||||
});
|
||||
mock.method(relayClient, "fetchEvents", async () => {
|
||||
liveCallback(
|
||||
relayEvent({
|
||||
id: "existing-theme",
|
||||
content: "ciphertext",
|
||||
created_at: 456,
|
||||
}),
|
||||
);
|
||||
return [];
|
||||
});
|
||||
try {
|
||||
const manager = new CommunityThemeSyncManager("alice");
|
||||
const { result, unsubscribe } = await manager.subscribeAndFetch(
|
||||
(remote) => {
|
||||
updates.push(remote);
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: "valid",
|
||||
remote: {
|
||||
preference: remotePreference,
|
||||
createdAt: 456,
|
||||
eventId: "existing-theme",
|
||||
},
|
||||
});
|
||||
assert.deepEqual(updates, [result.remote]);
|
||||
await unsubscribe();
|
||||
} finally {
|
||||
delete globalThis.window.__TAURI_INTERNALS__;
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("failed live setup cannot authorize hydration publishing", async () => {
|
||||
mock.method(relayClient, "subscribeLive", () =>
|
||||
Promise.reject(new Error("subscription failed")),
|
||||
);
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
try {
|
||||
const manager = new CommunityThemeSyncManager("alice");
|
||||
const { result } = await manager.subscribeAndFetch(() => {});
|
||||
|
||||
assert.deepEqual(result, { status: "unavailable" });
|
||||
assert.equal(communityThemeHydrationRemote(result), null);
|
||||
} finally {
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("EOSE-confirmed absence authorizes hydration seeding after live delivery drains", async () => {
|
||||
let liveCallback;
|
||||
mock.method(relayClient, "subscribeLive", (_filter, callback, onReady) => {
|
||||
liveCallback = callback;
|
||||
onReady("eose");
|
||||
return Promise.resolve(async () => {});
|
||||
});
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
try {
|
||||
const manager = new CommunityThemeSyncManager("alice");
|
||||
const { result, unsubscribe } = await manager.subscribeAndFetch(() => {});
|
||||
|
||||
assert.equal(typeof liveCallback, "function");
|
||||
assert.deepEqual(result, { status: "confirmed-absent" });
|
||||
assert.equal(shouldSeedCommunityTheme(result), true);
|
||||
await unsubscribe();
|
||||
} finally {
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("unreadable live state cannot authorize hydration publishing", async () => {
|
||||
let liveCallback;
|
||||
globalThis.window ??= {};
|
||||
globalThis.window.__TAURI_INTERNALS__ = {
|
||||
invoke(command) {
|
||||
if (command === "nip44_decrypt_from_self") {
|
||||
return Promise.reject(new Error("unsupported payload"));
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
},
|
||||
};
|
||||
mock.method(relayClient, "subscribeLive", (_filter, callback, onReady) => {
|
||||
liveCallback = callback;
|
||||
onReady("eose");
|
||||
return Promise.resolve(async () => {});
|
||||
});
|
||||
mock.method(relayClient, "fetchEvents", async () => {
|
||||
liveCallback(relayEvent({ content: "future-ciphertext" }));
|
||||
return [];
|
||||
});
|
||||
try {
|
||||
const manager = new CommunityThemeSyncManager("alice");
|
||||
const { result, unsubscribe } = await manager.subscribeAndFetch(() => {});
|
||||
|
||||
assert.deepEqual(result, { status: "invalid" });
|
||||
assert.equal(communityThemeHydrationRemote(result), null);
|
||||
await unsubscribe();
|
||||
} finally {
|
||||
delete globalThis.window.__TAURI_INTERNALS__;
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("apparently ready live setup cannot authorize hydration publishing", async () => {
|
||||
// Timeout readiness is not proof that the live REQ reached EOSE.
|
||||
mock.method(relayClient, "subscribeLive", (_filter, _callback, onReady) => {
|
||||
onReady("timeout");
|
||||
return Promise.resolve(async () => {});
|
||||
});
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
try {
|
||||
const manager = new CommunityThemeSyncManager("alice");
|
||||
const { result, unsubscribe } = await manager.subscribeAndFetch(() => {});
|
||||
|
||||
assert.deepEqual(result, { status: "unavailable" });
|
||||
assert.equal(communityThemeHydrationRemote(result), null);
|
||||
await unsubscribe();
|
||||
} finally {
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("fetch reports relay failures as unavailable rather than absent", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () =>
|
||||
Promise.reject(new Error("offline")),
|
||||
@@ -116,8 +258,23 @@ test("fetch reports relay failures as unavailable rather than absent", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("only confirmed absence permits seeding relay state", () => {
|
||||
assert.equal(shouldSeedCommunityTheme({ status: "absent" }), true);
|
||||
test("hydration applies valid remote state and seeds only confirmed absence", () => {
|
||||
const remote = {
|
||||
preference,
|
||||
createdAt: 123,
|
||||
eventId: "remote-theme",
|
||||
};
|
||||
assert.equal(
|
||||
communityThemeHydrationRemote({ status: "confirmed-absent" }),
|
||||
null,
|
||||
);
|
||||
assert.equal(communityThemeHydrationRemote({ status: "invalid" }), null);
|
||||
assert.equal(communityThemeHydrationRemote({ status: "unavailable" }), null);
|
||||
assert.equal(
|
||||
communityThemeHydrationRemote({ status: "valid", remote }),
|
||||
remote,
|
||||
);
|
||||
assert.equal(shouldSeedCommunityTheme({ status: "confirmed-absent" }), true);
|
||||
assert.equal(shouldSeedCommunityTheme({ status: "invalid" }), false);
|
||||
assert.equal(shouldSeedCommunityTheme({ status: "unavailable" }), false);
|
||||
});
|
||||
|
||||
@@ -44,10 +44,21 @@ export function isNewerCommunityThemeCoordinate(
|
||||
);
|
||||
}
|
||||
|
||||
export type CommunityThemeHydrationResult =
|
||||
| { status: "valid"; remote: RemoteCommunityTheme }
|
||||
| { status: "confirmed-absent" }
|
||||
| { status: "invalid" | "unavailable" };
|
||||
|
||||
export function communityThemeHydrationRemote(
|
||||
result: CommunityThemeHydrationResult,
|
||||
): RemoteCommunityTheme | null {
|
||||
return result.status === "valid" ? result.remote : null;
|
||||
}
|
||||
|
||||
export function shouldSeedCommunityTheme(
|
||||
result: RemoteCommunityThemeResult,
|
||||
result: CommunityThemeHydrationResult,
|
||||
): boolean {
|
||||
return result.status === "absent";
|
||||
return result.status === "confirmed-absent";
|
||||
}
|
||||
|
||||
async function decryptAndParse(
|
||||
@@ -70,6 +81,8 @@ export class CommunityThemeSyncManager {
|
||||
private destroyed = false;
|
||||
private lastRemoteCreatedAt = 0;
|
||||
private lastRemoteEventId = "";
|
||||
private latestRemote: RemoteCommunityTheme | null = null;
|
||||
private observedInvalidLiveRemote = false;
|
||||
private lastPublished: PublishedCommunityTheme | null = null;
|
||||
private pending: CommunityThemePreference | null = null;
|
||||
private publishInFlight = false;
|
||||
@@ -107,8 +120,9 @@ export class CommunityThemeSyncManager {
|
||||
) {
|
||||
this.lastRemoteCreatedAt = remote.createdAt;
|
||||
this.lastRemoteEventId = remote.eventId;
|
||||
this.latestRemote = remote;
|
||||
}
|
||||
return { status: "valid", remote };
|
||||
return { status: "valid", remote: this.latestRemote ?? remote };
|
||||
} catch {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
@@ -162,6 +176,7 @@ export class CommunityThemeSyncManager {
|
||||
) {
|
||||
this.lastRemoteCreatedAt = remote.createdAt;
|
||||
this.lastRemoteEventId = remote.eventId;
|
||||
this.latestRemote = remote;
|
||||
}
|
||||
const lastPublished = this.lastPublished;
|
||||
if (
|
||||
@@ -284,6 +299,85 @@ export class CommunityThemeSyncManager {
|
||||
await Promise.allSettled([...this.remoteProcessing]);
|
||||
}
|
||||
|
||||
async subscribeAndFetch(
|
||||
onUpdate: (remote: RemoteCommunityTheme) => void,
|
||||
): Promise<{
|
||||
result: CommunityThemeHydrationResult;
|
||||
unsubscribe: () => Promise<void>;
|
||||
}> {
|
||||
// Establish live delivery before querying history. A relay can deliver a
|
||||
// replacement while an empty history query is in flight; subscribing
|
||||
// second would leave a blind spot where the controller seeds defaults over
|
||||
// an existing preference.
|
||||
let unsubscribe: () => Promise<void> = async () => {};
|
||||
const readiness = {
|
||||
value: "failed" as "eose" | "closed" | "timeout" | "failed",
|
||||
};
|
||||
const setReadiness = (result: "eose" | "closed" | "timeout") => {
|
||||
readiness.value = result;
|
||||
};
|
||||
try {
|
||||
unsubscribe = await relayClient.subscribeLive(
|
||||
{
|
||||
kinds: [KIND_COMMUNITY_THEME],
|
||||
authors: [this.pubkey],
|
||||
"#d": [D_TAG],
|
||||
limit: 0,
|
||||
},
|
||||
(event: RelayEvent) => this.processLiveEvent(event, onUpdate),
|
||||
setReadiness,
|
||||
);
|
||||
} catch {
|
||||
// History still provides a safe snapshot when live setup is temporarily
|
||||
// unavailable; reconnect catch-up can still restore later relay state.
|
||||
}
|
||||
const result = await this.fetchRemote();
|
||||
await this.waitForDeliveredRemotes();
|
||||
let hydrationResult: CommunityThemeHydrationResult;
|
||||
if (this.latestRemote) {
|
||||
hydrationResult = { status: "valid", remote: this.latestRemote };
|
||||
} else if (result.status === "valid") {
|
||||
hydrationResult = result;
|
||||
} else if (result.status === "invalid") {
|
||||
hydrationResult = { status: "invalid" };
|
||||
} else if (result.status === "unavailable") {
|
||||
hydrationResult = { status: "unavailable" };
|
||||
} else if (this.observedInvalidLiveRemote) {
|
||||
hydrationResult = { status: "invalid" };
|
||||
} else if (readiness.value === "eose") {
|
||||
hydrationResult = { status: "confirmed-absent" };
|
||||
} else {
|
||||
hydrationResult = { status: "unavailable" };
|
||||
}
|
||||
return { result: hydrationResult, unsubscribe };
|
||||
}
|
||||
|
||||
private processLiveEvent(
|
||||
event: RelayEvent,
|
||||
onUpdate: (remote: RemoteCommunityTheme) => void,
|
||||
): void {
|
||||
if (event.pubkey !== this.pubkey || this.destroyed) return;
|
||||
const processing = decryptAndParse(event).then((remote) => {
|
||||
if (this.destroyed) return;
|
||||
if (!remote) {
|
||||
this.observedInvalidLiveRemote = true;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isNewerCommunityThemeCoordinate(remote, {
|
||||
createdAt: this.lastRemoteCreatedAt,
|
||||
eventId: this.lastRemoteEventId,
|
||||
})
|
||||
) {
|
||||
this.lastRemoteCreatedAt = remote.createdAt;
|
||||
this.lastRemoteEventId = remote.eventId;
|
||||
this.latestRemote = remote;
|
||||
}
|
||||
onUpdate(remote);
|
||||
});
|
||||
this.trackRemoteProcessing(processing);
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
onUpdate: (remote: RemoteCommunityTheme) => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
@@ -294,23 +388,7 @@ export class CommunityThemeSyncManager {
|
||||
"#d": [D_TAG],
|
||||
limit: 0,
|
||||
},
|
||||
(event: RelayEvent) => {
|
||||
if (event.pubkey !== this.pubkey || this.destroyed) return;
|
||||
const processing = decryptAndParse(event).then((remote) => {
|
||||
if (!remote || this.destroyed) return;
|
||||
if (
|
||||
isNewerCommunityThemeCoordinate(remote, {
|
||||
createdAt: this.lastRemoteCreatedAt,
|
||||
eventId: this.lastRemoteEventId,
|
||||
})
|
||||
) {
|
||||
this.lastRemoteCreatedAt = remote.createdAt;
|
||||
this.lastRemoteEventId = remote.eventId;
|
||||
}
|
||||
onUpdate(remote);
|
||||
});
|
||||
this.trackRemoteProcessing(processing);
|
||||
},
|
||||
(event: RelayEvent) => this.processLiveEvent(event, onUpdate),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user