Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions desktop/src/shared/api/relayClientSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import {
getTextPayload,
type ConnectionState,
type LiveSubscriptionReadiness,
type PendingEvent,
type RelaySubscription,
type RelaySubscriptionFilter,
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -887,6 +888,7 @@ export class RelayClient {
}

private handleEose(subId: string) {
this.flushEventBuffer(); // Deliver preceding EVENT frames before EOSE.
handleSubscriptionEose({
subscriptions: this.subscriptions,
subId,
Expand Down Expand Up @@ -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();
}
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/shared/api/relayClientShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions desktop/src/shared/api/relayClosedRecovery.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/shared/api/relayClosedRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
67 changes: 40 additions & 27 deletions desktop/src/shared/theme/CommunityThemeController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
clearCommunityThemeOutbox,
communityThemeApplyExpectation,
communityThemePersistenceAction,
communityThemeScopeFallback,
hasMigratedCommunityTheme,
markCommunityThemeMigrated,
readCommunityThemeOutbox,
Expand All @@ -19,6 +20,7 @@ import {
} from "./communityThemePreference";
import {
CommunityThemeSyncManager,
communityThemeHydrationRemote,
isNewerCommunityThemeCoordinate,
shouldSeedCommunityTheme,
type RemoteCommunityTheme,
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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") {
Expand Down
28 changes: 28 additions & 0 deletions desktop/src/shared/theme/communityThemePreference.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
communityThemeApplyExpectation,
communityThemeOutboxKey,
communityThemePersistenceAction,
communityThemeScopeFallback,
communityThemeStorageKey,
parseCommunityThemePreference,
readCommunityThemeOutbox,
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion desktop/src/shared/theme/communityThemePreference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}

/**
Expand Down
Loading
Loading