mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
fix(theme): preserve in-flight preference updates
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -656,7 +656,7 @@ function ThemeSettingsCard() {
|
||||
Icon,
|
||||
}))}
|
||||
testId="appearance-color-mode-control"
|
||||
value={selectedMode}
|
||||
value={activeMode}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
isNewerCommunityThemeCoordinate,
|
||||
shouldSeedCommunityTheme,
|
||||
} from "./communityThemeSync.ts";
|
||||
import {
|
||||
clearCommunityThemeOutbox,
|
||||
readCommunityThemeOutbox,
|
||||
writeCommunityThemeOutbox,
|
||||
} from "./communityThemePreference.ts";
|
||||
|
||||
const preference = {
|
||||
version: 1,
|
||||
@@ -18,6 +23,15 @@ const preference = {
|
||||
prominentActiveTab: false,
|
||||
};
|
||||
|
||||
function localStorageStub() {
|
||||
const data = new Map();
|
||||
return {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => data.set(key, String(value)),
|
||||
removeItem: (key) => data.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
function installFakeTimer() {
|
||||
globalThis.window ??= {};
|
||||
let callback = null;
|
||||
@@ -745,6 +759,87 @@ test("remote accepted after submit is republished after the stale event settles"
|
||||
}
|
||||
});
|
||||
|
||||
test("a user edit after migration cancellation survives the stale submission", async () => {
|
||||
const timer = installFakeTimer();
|
||||
const firstPublish = Promise.withResolvers();
|
||||
const published = [];
|
||||
const acknowledgements = [];
|
||||
const userPreference = { ...preference, accent: "#ef4444" };
|
||||
let signed = 0;
|
||||
globalThis.window.localStorage = localStorageStub();
|
||||
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", (event) => {
|
||||
acknowledgements.push(event);
|
||||
clearCommunityThemeOutbox(
|
||||
"alice",
|
||||
"wss://relay.example",
|
||||
event.preference,
|
||||
);
|
||||
});
|
||||
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);
|
||||
|
||||
writeCommunityThemeOutbox("alice", "wss://relay.example", userPreference);
|
||||
manager.publish(userPreference);
|
||||
firstPublish.resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await waitUntil(() => timer.pending());
|
||||
|
||||
assert.deepEqual(manager.getPending(), userPreference);
|
||||
assert.deepEqual(
|
||||
readCommunityThemeOutbox("alice", "wss://relay.example"),
|
||||
userPreference,
|
||||
);
|
||||
|
||||
timer.fire();
|
||||
await waitUntil(() => acknowledgements.length === 1);
|
||||
assert.equal(published.length, 2);
|
||||
assert.ok(published[1].created_at > published[0].created_at + 100);
|
||||
assert.deepEqual(acknowledgements[0].preference, userPreference);
|
||||
assert.equal(
|
||||
readCommunityThemeOutbox("alice", "wss://relay.example"),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
delete globalThis.window.__TAURI_INTERNALS__;
|
||||
delete globalThis.window.localStorage;
|
||||
timer.restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("migration self-echo during submission acknowledges without replacement", async () => {
|
||||
const timer = installFakeTimer();
|
||||
const publish = Promise.withResolvers();
|
||||
|
||||
@@ -105,8 +105,12 @@ export class CommunityThemeSyncManager {
|
||||
private readonly onPublished: (published: PublishedCommunityTheme) => void;
|
||||
private readonly getLegacyFallback: () => CommunityThemePreference;
|
||||
private publishGeneration = 0;
|
||||
private publishRevision = 0;
|
||||
private activeSubmittedPublish: PublishedCommunityTheme | null = null;
|
||||
private replacementAfterCancel: CommunityThemePreference | null = null;
|
||||
private replacementAfterCancel: {
|
||||
preference: CommunityThemePreference;
|
||||
publishRevision: number;
|
||||
} | null = null;
|
||||
|
||||
constructor(
|
||||
pubkey: string,
|
||||
@@ -155,6 +159,7 @@ export class CommunityThemeSyncManager {
|
||||
|
||||
publish(preference: CommunityThemePreference): void {
|
||||
if (this.destroyed) return;
|
||||
this.publishRevision += 1;
|
||||
this.pending = preference;
|
||||
this.publishRetryAttempt = 0;
|
||||
this.schedulePublish(DEBOUNCE_MS);
|
||||
@@ -177,17 +182,24 @@ export class CommunityThemeSyncManager {
|
||||
this.activeSubmittedPublish = null;
|
||||
const preference = this.pending;
|
||||
const generation = this.publishGeneration;
|
||||
const publishRevision = this.publishRevision;
|
||||
void this.doPublish(preference, generation).finally(() => {
|
||||
this.publishInFlight = false;
|
||||
this.activeSubmittedPublish = null;
|
||||
if (this.replacementAfterCancel) {
|
||||
this.pending = this.replacementAfterCancel;
|
||||
this.replacementAfterCancel = null;
|
||||
const replacement = this.replacementAfterCancel;
|
||||
this.replacementAfterCancel = null;
|
||||
if (
|
||||
replacement &&
|
||||
this.pending === null &&
|
||||
replacement.publishRevision === this.publishRevision
|
||||
) {
|
||||
this.pending = replacement.preference;
|
||||
}
|
||||
if (
|
||||
!this.destroyed &&
|
||||
this.pending &&
|
||||
!sameCommunityThemePreference(this.pending, preference) &&
|
||||
(this.publishRevision !== publishRevision ||
|
||||
!sameCommunityThemePreference(this.pending, preference)) &&
|
||||
this.debounceTimer === null
|
||||
) {
|
||||
this.schedulePublish(0);
|
||||
@@ -234,7 +246,12 @@ export class CommunityThemeSyncManager {
|
||||
}
|
||||
const submitted = this.activeSubmittedPublish !== null;
|
||||
this.replacementAfterCancel =
|
||||
submitted && replacementAfterSubmit ? replacementAfterSubmit : null;
|
||||
submitted && replacementAfterSubmit
|
||||
? {
|
||||
preference: replacementAfterSubmit,
|
||||
publishRevision: this.publishRevision,
|
||||
}
|
||||
: null;
|
||||
this.publishGeneration += 1;
|
||||
if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
|
||||
@@ -62,6 +62,7 @@ class CommunityThemeSyncManager {
|
||||
int _publishRetryAttempt = 0;
|
||||
bool _publishInFlight = false;
|
||||
bool _publishRequestedWhileInFlight = false;
|
||||
int _remoteRevision = 0;
|
||||
bool _hydrationObserved = false;
|
||||
bool _disposed = false;
|
||||
|
||||
@@ -333,6 +334,7 @@ class CommunityThemeSyncManager {
|
||||
// a desktop client's glass and prominent-tab choices. Bookkeeping stays on
|
||||
// the local `preference` so the outbox ack contract is unaffected.
|
||||
final remote = _lastRemote?.preference;
|
||||
final remoteRevision = _remoteRevision;
|
||||
final outgoing = remote == null
|
||||
? preference
|
||||
: preference.mergeDesktopAppearanceFrom(remote);
|
||||
@@ -358,6 +360,12 @@ class CommunityThemeSyncManager {
|
||||
if (published == null) {
|
||||
throw StateError('Signed event coordinate unavailable');
|
||||
}
|
||||
if (_remoteRevision != remoteRevision) {
|
||||
_lastPublished = null;
|
||||
_publishRetryAttempt = 0;
|
||||
if (_pending == preference) _schedulePublish(Duration.zero);
|
||||
return;
|
||||
}
|
||||
final publishedCoordinateIsStale =
|
||||
_lastCreatedAt > published.createdAt ||
|
||||
(_lastCreatedAt == published.createdAt &&
|
||||
@@ -427,6 +435,7 @@ class CommunityThemeSyncManager {
|
||||
_lastCreatedAt = remote.createdAt;
|
||||
_lastEventId = remote.eventId;
|
||||
_lastRemote = remote;
|
||||
_remoteRevision++;
|
||||
_observeHydration();
|
||||
if (_pending != null) {
|
||||
_lastPublished = null;
|
||||
|
||||
@@ -175,11 +175,12 @@ void main() {
|
||||
tester,
|
||||
SettingsPage(
|
||||
profileHeader: const SizedBox.shrink(),
|
||||
invitePageBuilder: (_) => const SizedBox.shrink(),
|
||||
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Theme · This community'), findsOneWidget);
|
||||
expect(find.text('Style · This community'), findsOneWidget);
|
||||
expect(find.text('Color mode'), findsOneWidget);
|
||||
expect(find.text('Color style'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -304,6 +304,64 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
're-merges desktop fields when remote advances during submission',
|
||||
() async {
|
||||
final firstSubmission = Completer<void>();
|
||||
final relay = _FakeSignedRelay(
|
||||
eventId: 'a-mobile',
|
||||
firstSubmissionGate: firstSubmission.future,
|
||||
);
|
||||
final session = _FakeSession();
|
||||
final acknowledgements = <CommunityThemePreference>[];
|
||||
final manager = _manager(
|
||||
session,
|
||||
relay,
|
||||
onPublished: acknowledgements.add,
|
||||
);
|
||||
await manager.initialize();
|
||||
manager.publish(local);
|
||||
final firstFlush = manager.flush();
|
||||
await _waitUntil(() => relay.attempts == 1);
|
||||
|
||||
const desktop = CommunityThemePreference(
|
||||
theme: 'dracula',
|
||||
accent: '#ef4444',
|
||||
followSystem: false,
|
||||
glassBackground: false,
|
||||
glassOpacity: 80,
|
||||
prominentActiveTab: true,
|
||||
);
|
||||
session.emit(
|
||||
_event(
|
||||
id: 'z-desktop',
|
||||
createdAt: relay.startedCreatedAts.single,
|
||||
content: jsonEncode(desktop.toJson()),
|
||||
),
|
||||
);
|
||||
firstSubmission.complete();
|
||||
await firstFlush;
|
||||
|
||||
expect(acknowledgements, isEmpty);
|
||||
expect(manager.pending, local);
|
||||
await _waitUntil(() => relay.attempts == 2);
|
||||
|
||||
final recovered =
|
||||
jsonDecode(relay.submissions[1].content) as Map<String, dynamic>;
|
||||
expect(recovered['theme'], local.theme);
|
||||
expect(recovered['accent'], local.accent);
|
||||
expect(recovered['glassBackground'], false);
|
||||
expect(recovered['glassOpacity'], 80);
|
||||
expect(recovered['prominentActiveTab'], true);
|
||||
expect(
|
||||
relay.submittedEvents[1].createdAt,
|
||||
greaterThan(relay.startedCreatedAts.first),
|
||||
);
|
||||
expect(acknowledgements, [local]);
|
||||
expect(manager.pending, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('remote coordinate advances pending local publish timestamp', () async {
|
||||
final relay = _FakeSignedRelay();
|
||||
final session = _FakeSession();
|
||||
@@ -709,6 +767,7 @@ class _FakeSignedRelay implements SignedEventRelay {
|
||||
final String eventId;
|
||||
final Future<void>? firstSubmissionGate;
|
||||
int attempts = 0;
|
||||
final startedCreatedAts = <int>[];
|
||||
final submissions = <_Submission>[];
|
||||
final submittedEvents = <NostrEvent>[];
|
||||
@override
|
||||
@@ -722,6 +781,7 @@ class _FakeSignedRelay implements SignedEventRelay {
|
||||
void Function(NostrEvent)? onSigned,
|
||||
}) async {
|
||||
attempts++;
|
||||
startedCreatedAts.add(createdAt ?? 0);
|
||||
if (attempts == 1 && firstSubmissionGate != null) {
|
||||
await firstSubmissionGate;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user