mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
Keep the submitted migration coordinate so its relay echo can complete the original acknowledgement instead of being treated as competing remote state. Co-authored-by: Kenny Lopez <klopez4212@gmail.com> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: kenny lopez <klopez4212@gmail.com>
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
import { relayClient } from "@/shared/api/relayClient";
|
|
import {
|
|
nip44DecryptFromSelf,
|
|
nip44EncryptToSelf,
|
|
signRelayEvent,
|
|
} from "@/shared/api/tauri";
|
|
import type { RelayEvent } from "@/shared/api/types";
|
|
import { KIND_COMMUNITY_THEME } from "@/shared/constants/kinds";
|
|
import {
|
|
DEFAULT_COMMUNITY_THEME,
|
|
parseCommunityThemePreference,
|
|
sameCommunityThemePreference,
|
|
type CommunityThemePreference,
|
|
} from "./communityThemePreference";
|
|
|
|
const D_TAG = "community-theme";
|
|
const DEBOUNCE_MS = 2_000;
|
|
const PUBLISH_RETRY_BASE_MS = 1_000;
|
|
const PUBLISH_RETRY_MAX_MS = 30_000;
|
|
|
|
export type PublishedCommunityTheme = {
|
|
preference: CommunityThemePreference;
|
|
createdAt: number;
|
|
eventId: string;
|
|
};
|
|
|
|
export type RemoteCommunityTheme = {
|
|
preference: CommunityThemePreference;
|
|
createdAt: number;
|
|
eventId: string;
|
|
needsUpgrade: boolean;
|
|
};
|
|
|
|
export type RemoteCommunityThemeResult =
|
|
| { status: "valid"; remote: RemoteCommunityTheme }
|
|
| { status: "absent" | "invalid" | "unavailable" };
|
|
|
|
export function isNewerCommunityThemeCoordinate(
|
|
candidate: { createdAt: number; eventId: string },
|
|
current: { createdAt: number; eventId: string },
|
|
): boolean {
|
|
return (
|
|
candidate.createdAt > current.createdAt ||
|
|
(candidate.createdAt === current.createdAt &&
|
|
(current.eventId === "" || candidate.eventId < current.eventId))
|
|
);
|
|
}
|
|
|
|
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: CommunityThemeHydrationResult,
|
|
): boolean {
|
|
return result.status === "confirmed-absent";
|
|
}
|
|
|
|
async function decryptAndParse(
|
|
event: RelayEvent,
|
|
legacyFallback: () => CommunityThemePreference,
|
|
): Promise<RemoteCommunityTheme | null> {
|
|
try {
|
|
const plaintext = await nip44DecryptFromSelf(event.content);
|
|
const parsed = JSON.parse(plaintext);
|
|
const preference = parseCommunityThemePreference(parsed, legacyFallback());
|
|
return preference
|
|
? {
|
|
preference,
|
|
createdAt: event.created_at,
|
|
eventId: event.id,
|
|
needsUpgrade:
|
|
typeof parsed === "object" &&
|
|
parsed !== null &&
|
|
(!Object.hasOwn(parsed, "glassBackground") ||
|
|
!Object.hasOwn(parsed, "glassOpacity") ||
|
|
!Object.hasOwn(parsed, "prominentActiveTab")),
|
|
}
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export class CommunityThemeSyncManager {
|
|
private readonly pubkey: string;
|
|
private debounceTimer: number | null = null;
|
|
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;
|
|
private publishRetryAttempt = 0;
|
|
private readonly remoteProcessing = new Set<Promise<unknown>>();
|
|
private readonly onPublished: (published: PublishedCommunityTheme) => void;
|
|
private readonly getLegacyFallback: () => CommunityThemePreference;
|
|
private publishGeneration = 0;
|
|
private activeSubmittedPublish: PublishedCommunityTheme | null = null;
|
|
private replacementAfterCancel: CommunityThemePreference | null = null;
|
|
|
|
constructor(
|
|
pubkey: string,
|
|
onPublished: (published: PublishedCommunityTheme) => void = () => {},
|
|
legacyFallback:
|
|
| CommunityThemePreference
|
|
| (() => CommunityThemePreference) = DEFAULT_COMMUNITY_THEME,
|
|
) {
|
|
this.pubkey = pubkey;
|
|
this.onPublished = onPublished;
|
|
this.getLegacyFallback =
|
|
typeof legacyFallback === "function"
|
|
? legacyFallback
|
|
: () => legacyFallback;
|
|
}
|
|
|
|
async fetchRemote(): Promise<RemoteCommunityThemeResult> {
|
|
try {
|
|
const events = await relayClient.fetchEvents({
|
|
kinds: [KIND_COMMUNITY_THEME],
|
|
authors: [this.pubkey],
|
|
"#d": [D_TAG],
|
|
limit: 1,
|
|
});
|
|
if (events.length === 0) return { status: "absent" };
|
|
if (events[0].pubkey !== this.pubkey) return { status: "invalid" };
|
|
const processing = decryptAndParse(events[0], this.getLegacyFallback);
|
|
this.trackRemoteProcessing(processing);
|
|
const remote = await processing;
|
|
if (!remote) return { status: "invalid" };
|
|
if (
|
|
isNewerCommunityThemeCoordinate(remote, {
|
|
createdAt: this.lastRemoteCreatedAt,
|
|
eventId: this.lastRemoteEventId,
|
|
})
|
|
) {
|
|
this.lastRemoteCreatedAt = remote.createdAt;
|
|
this.lastRemoteEventId = remote.eventId;
|
|
this.latestRemote = remote;
|
|
}
|
|
return { status: "valid", remote: this.latestRemote ?? remote };
|
|
} catch {
|
|
return { status: "unavailable" };
|
|
}
|
|
}
|
|
|
|
publish(preference: CommunityThemePreference): void {
|
|
if (this.destroyed) return;
|
|
this.pending = preference;
|
|
this.publishRetryAttempt = 0;
|
|
this.schedulePublish(DEBOUNCE_MS);
|
|
}
|
|
|
|
private schedulePublish(delayMs: number): void {
|
|
if (this.destroyed) return;
|
|
if (this.debounceTimer !== null) {
|
|
window.clearTimeout(this.debounceTimer);
|
|
}
|
|
this.debounceTimer = window.setTimeout(() => {
|
|
this.debounceTimer = null;
|
|
this.startPublish();
|
|
}, delayMs);
|
|
}
|
|
|
|
private startPublish(): void {
|
|
if (this.destroyed || this.publishInFlight || !this.pending) return;
|
|
this.publishInFlight = true;
|
|
this.activeSubmittedPublish = null;
|
|
const preference = this.pending;
|
|
const generation = this.publishGeneration;
|
|
void this.doPublish(preference, generation).finally(() => {
|
|
this.publishInFlight = false;
|
|
this.activeSubmittedPublish = null;
|
|
if (this.replacementAfterCancel) {
|
|
this.pending = this.replacementAfterCancel;
|
|
this.replacementAfterCancel = null;
|
|
}
|
|
if (
|
|
!this.destroyed &&
|
|
this.pending &&
|
|
!sameCommunityThemePreference(this.pending, preference) &&
|
|
this.debounceTimer === null
|
|
) {
|
|
this.schedulePublish(0);
|
|
}
|
|
});
|
|
}
|
|
|
|
getPending(): CommunityThemePreference | null {
|
|
return this.pending;
|
|
}
|
|
|
|
acceptRemote(remote: RemoteCommunityTheme): void {
|
|
if (
|
|
isNewerCommunityThemeCoordinate(remote, {
|
|
createdAt: this.lastRemoteCreatedAt,
|
|
eventId: this.lastRemoteEventId,
|
|
})
|
|
) {
|
|
this.lastRemoteCreatedAt = remote.createdAt;
|
|
this.lastRemoteEventId = remote.eventId;
|
|
this.latestRemote = remote;
|
|
}
|
|
const lastPublished = this.lastPublished;
|
|
if (
|
|
lastPublished &&
|
|
(lastPublished.createdAt !== remote.createdAt ||
|
|
lastPublished.eventId !== remote.eventId)
|
|
) {
|
|
this.lastPublished = null;
|
|
}
|
|
}
|
|
|
|
cancelPendingPublish(
|
|
replacementAfterSubmit?: CommunityThemePreference,
|
|
acceptedRemote?: RemoteCommunityTheme,
|
|
): boolean {
|
|
if (
|
|
this.activeSubmittedPublish &&
|
|
acceptedRemote &&
|
|
acceptedRemote.createdAt === this.activeSubmittedPublish.createdAt &&
|
|
acceptedRemote.eventId === this.activeSubmittedPublish.eventId
|
|
) {
|
|
return false;
|
|
}
|
|
const submitted = this.activeSubmittedPublish !== null;
|
|
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,
|
|
generation: number,
|
|
): Promise<void> {
|
|
const wasCancelled = () =>
|
|
this.destroyed || generation !== this.publishGeneration;
|
|
try {
|
|
const lastPublished = this.lastPublished;
|
|
if (
|
|
wasCancelled() ||
|
|
(lastPublished &&
|
|
sameCommunityThemePreference(lastPublished.preference, preference))
|
|
) {
|
|
if (
|
|
this.pending &&
|
|
sameCommunityThemePreference(this.pending, preference)
|
|
) {
|
|
this.pending = null;
|
|
if (lastPublished) this.onPublished(lastPublished);
|
|
}
|
|
return;
|
|
}
|
|
const ciphertext = await nip44EncryptToSelf(JSON.stringify(preference));
|
|
if (wasCancelled()) return;
|
|
const event = await signRelayEvent({
|
|
kind: KIND_COMMUNITY_THEME,
|
|
content: ciphertext,
|
|
createdAt: Math.max(
|
|
Math.floor(Date.now() / 1_000),
|
|
this.lastRemoteCreatedAt + 1,
|
|
),
|
|
tags: [
|
|
["d", D_TAG],
|
|
["t", D_TAG],
|
|
],
|
|
});
|
|
if (wasCancelled()) return;
|
|
this.activeSubmittedPublish = {
|
|
preference,
|
|
createdAt: event.created_at,
|
|
eventId: event.id,
|
|
};
|
|
await relayClient.publishEvent(
|
|
event,
|
|
"Timed out publishing community theme.",
|
|
"Failed to publish community theme.",
|
|
);
|
|
await this.waitForDeliveredRemotes();
|
|
if (wasCancelled()) return;
|
|
const published = {
|
|
preference,
|
|
createdAt: event.created_at,
|
|
eventId: event.id,
|
|
};
|
|
const eventLostToRemote = isNewerCommunityThemeCoordinate(
|
|
{
|
|
createdAt: this.lastRemoteCreatedAt,
|
|
eventId: this.lastRemoteEventId,
|
|
},
|
|
published,
|
|
);
|
|
if (eventLostToRemote) {
|
|
this.lastPublished = null;
|
|
this.publishRetryAttempt = 0;
|
|
if (
|
|
this.pending &&
|
|
sameCommunityThemePreference(this.pending, preference)
|
|
) {
|
|
this.schedulePublish(0);
|
|
}
|
|
return;
|
|
}
|
|
this.lastRemoteCreatedAt = event.created_at;
|
|
this.lastRemoteEventId = event.id;
|
|
this.lastPublished = published;
|
|
this.publishRetryAttempt = 0;
|
|
if (
|
|
this.pending &&
|
|
sameCommunityThemePreference(this.pending, preference)
|
|
) {
|
|
this.pending = null;
|
|
}
|
|
this.onPublished(published);
|
|
} catch (error) {
|
|
console.warn("[communityThemeSync] publish failed:", error);
|
|
if (
|
|
wasCancelled() ||
|
|
!this.pending ||
|
|
!sameCommunityThemePreference(this.pending, preference)
|
|
) {
|
|
return;
|
|
}
|
|
const delay = Math.min(
|
|
PUBLISH_RETRY_BASE_MS * 2 ** this.publishRetryAttempt,
|
|
PUBLISH_RETRY_MAX_MS,
|
|
);
|
|
this.publishRetryAttempt += 1;
|
|
this.schedulePublish(delay);
|
|
}
|
|
}
|
|
|
|
private trackRemoteProcessing(task: Promise<unknown>): void {
|
|
this.remoteProcessing.add(task);
|
|
void task.then(
|
|
() => this.remoteProcessing.delete(task),
|
|
() => this.remoteProcessing.delete(task),
|
|
);
|
|
}
|
|
|
|
private async waitForDeliveredRemotes(): Promise<void> {
|
|
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, this.getLegacyFallback).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>> {
|
|
return relayClient.subscribeLive(
|
|
{
|
|
kinds: [KIND_COMMUNITY_THEME],
|
|
authors: [this.pubkey],
|
|
"#d": [D_TAG],
|
|
limit: 0,
|
|
},
|
|
(event: RelayEvent) => this.processLiveEvent(event, onUpdate),
|
|
);
|
|
}
|
|
|
|
destroy(): void {
|
|
this.destroyed = true;
|
|
this.cancelPendingPublish();
|
|
}
|
|
}
|