mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
## Summary Bring desktop Solo chats and project organization to mobile, with synced collapsible sections, shared unread highlights and tab badges, agent selection, shared search, native iOS creation sheets, and inline typing bubbles that transition into replies. Resolve Solo placement for every chat entry route, retain encrypted organization snapshots for offline reading, and let users resume or discard unfinished chat and project setup without deleting existing messages. ### Related issue Related: #6565; expandable agent transcripts remain separate work. ### Testing - Fresh Solo read state seeds each active chat from its captured initial last-message timestamp before exposing row/section/tab unread state. Baselines survive intervening channel updates and reset across identity/community boundaries while surviving read-state readiness changes. Regressions cover multiple chats, events before read-state readiness and during/after seeding, and identity changes; removing either the seed or the display gate fails them. - Chat completion reconciles a retained Solo-save journal even when placement is already confirmed. False/throwing cleanup regressions ensure the outer intent remains until reconciliation and only one placement is published. Typing terminal-close loops remain bounded; the retry budget resets after an uninterrupted eight-second health interval or transport reconnect. - Typing retry budgets reset after sustained recovery; invalid Solo updates retain cached rows with a retry warning that survives failed refreshes. Project names use the editors’ 120-grapheme limit, including emoji and combining characters. Regression coverage exercises repeated recovery, invalid live/refresh heads, and character-limit boundaries. - Rebased onto current main, preserving the newer Search controls, Huddle timeline filtering, and message-media layout. - Failed project journal writes reload storage before claiming recovery and reopen the entered name for retry. Typing subscription rejections are caught; terminal closures clear stale indicators and retry with a bounded backoff, canceled on disposal. Regression tests cover both false/throwing storage writes and initial/late subscription rejection. - Explicit ordinary destinations skip optional Solo lookup waits. Generic destinations perform one bounded lookup in the destination (search no longer duplicates it); an unavailable classification falls back to ordinary sending per the product owner's explicit choice. Positively identified Solo destinations use Solo providers. - Terminal Solo subscription closure preserves cached rows and exposes live-update Retry. Page coverage verifies warning visibility, usable rows during recovery, and a subsequent terminal close. Removing the warning or the immediate ordinary-route guard fails its regression. - Generic channel entries share an ordinary default when Solo classification is unavailable, per the requested product behavior. Known Solo placement takes precedence; callers can explicitly require unresolved placement to remain blocked. Bounded initial lookup preserves known Solo deep-link routing. - Dirty newest snapshots and offline-save warnings survive transport reconnects; Retry persists that same event even if the relay returns an older head. Community changes replace the service, and agent-directory reconnect refresh remains intact. - Pending-save discard reloads failed removals. Repeated reconnect testing confirms typing subscriptions are released on provider rebuild and disposal; removing the existing cleanup fails the regression. - Ordinary Home/create routes explicitly preserve private-channel sending during Solo failures; identified Solo sends use the Solo sender and explicit unresolved routes remain fail-closed. Solo unread replies beyond the initial 200-event page are loaded through the oldest-unread boundary. - Chat/project/agent cleanup reloads failed removals, retains same-process recovery, and protects replaced records. False/throw regressions verify retries do not duplicate creation or membership. Route, unread, and cleanup guard-removal mutations each fail their regression. - Ordinary channels, DMs, and forums remain sendable when optional Solo sync fails; explicit unresolved placement stays blocked. Controlled cache races retain the newest verified snapshot, keep usable chats visible, and retry offline persistence without stale writes. Both protection-removal regressions fail as expected. - Confirmed journal cleanup checks false/throwing removal and exact-record identity. Interrupted agent choices are visible immediately, identify the saved agent, and cover Resume/Later/Discard with accessible name/key-fallback controls. - Notification deep links preserve the contiguous history cursor; same-second messages infer the newest agent consistently. Failed offline-cache writes surface Retry, with false/throwing storage failures and offline restart covered. - Composite history cursors cover 350 same-second events plus older history; removing the ID cursor makes the regression fail. Agent changes now journal before granting access, with restart/failure and Resume/Later/Discard widget coverage. Signed malformed profiles remain isolated. - Native iOS avatar tests passed on the simulator: visible-row loading, three-request concurrency, streamed byte limits, response validation, downsampling/pixel limits, cancellation, and visible avatar updates. - Solo and Home share observed-event/read-marker logic. Widget coverage verifies unread rows, collapsed-section indicators, the tab badge before first Solo visit, message/thread reads, forced unread, and filtering archived/nonmember/unplaced chats. - Chat intent is saved before ownership and organization preflight; tests cover interrupted ownership lookup and a disconnect while the native creation form is open. - Mobile analysis, formatting, file-size checks, and full mobile tests passed at `1d4c41354`. - Project creation persists its encrypted name/UUID before connectivity checks or relay reads. Restart tests cover Resume/Later/Discard, scope isolation, stale-intent rejection, and lost acknowledgements. Removing the pre-network save makes all four production-flow regressions fail. - Regression coverage includes notification/Activity routing, inline replies, unknown-placement send protection, restart recovery/discard, and encrypted offline cache isolation/refresh. - Earlier feature build exercised on a physical iPhone with existing account/community data; review follow-ups have not been reinstalled on the device. UIKit agent-row accessibility checks passed on an iOS simulator. - Earlier cross-platform `just ci` stopped on the existing `buzz-acp` test `signals_cancel_startup_turn_and_pending_stdin_without_hanging` (process exit-code assertion); no Rust production changes are included in this PR. ### Remaining review limitation Agent discovery is bounded to 10 pages / 1,000 profiles with automatic retries disabled. Owner-scoped discovery remains open because the current relay ignores `#auth` filters; supporting that query requires a relay change. ### Snapshots Sample data, shared Flutter/Android surface. Native iOS uses platform sheets and controls. | Solo: projects and chats | New chat | Choose an agent | | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--01-solo.png" width="240" alt="Solo with collapsible projects and chats" /> | <img src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--02-new-chat.png" width="240" alt="New chat form" /> | <img src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--03-agent-picker.png" width="240" alt="Agent selection list" /> | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
517 lines
16 KiB
Dart
517 lines
16 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter/widgets.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
import 'package:nostr/nostr.dart' as nostr;
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import '../theme/theme_provider.dart';
|
||
|
||
import '../crypto/nip44.dart';
|
||
import '../relay/relay.dart';
|
||
import 'me_groups.dart';
|
||
import 'me_recovery.dart';
|
||
import 'me_journal.dart';
|
||
|
||
/// A live, account/community-scoped reader for desktop's encrypted Solo record.
|
||
final meGroupsProvider = StreamProvider<MeGroups>((ref) {
|
||
final service = ref.watch(meSyncProvider);
|
||
if (service.connectionState != null) {
|
||
ref.listen(relaySessionProvider.select((state) => state.status), (
|
||
previous,
|
||
next,
|
||
) {
|
||
if (previous != next && next == SessionStatus.connected) {
|
||
ref.invalidateSelf();
|
||
}
|
||
});
|
||
}
|
||
final controller = StreamController<MeGroups>();
|
||
var disposed = false;
|
||
void Function()? unsubscribe;
|
||
NostrEvent? latest;
|
||
var hasSnapshot = false;
|
||
var subscriptionClosed = false;
|
||
var syncFailed = false;
|
||
Future<void> accept(NostrEvent event) async {
|
||
if (disposed ||
|
||
(latest != null &&
|
||
event.id != latest!.id &&
|
||
!meEventIsNewer(event, latest!))) {
|
||
return;
|
||
}
|
||
try {
|
||
final value = service.decode(event);
|
||
// Reserve the highest verified head before any awaited persistence.
|
||
latest = event;
|
||
hasSnapshot = true;
|
||
controller.add(value);
|
||
await service.cache(event);
|
||
} catch (error, stack) {
|
||
if (!disposed) {
|
||
syncFailed = true;
|
||
service.syncClosed.value = true;
|
||
if (!hasSnapshot) controller.addError(error, stack);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> refresh() async {
|
||
try {
|
||
final remote = await service.head();
|
||
final candidate = service.latestCacheCandidate;
|
||
final event =
|
||
candidate != null &&
|
||
(remote == null || meEventIsNewer(candidate, remote))
|
||
? candidate
|
||
: remote;
|
||
if (disposed) return;
|
||
if (event != null) {
|
||
await accept(event);
|
||
} else if (latest == null) {
|
||
await service.cache(null);
|
||
if (disposed || latest != null) return;
|
||
hasSnapshot = true;
|
||
controller.add(const MeGroups());
|
||
}
|
||
} catch (error, stack) {
|
||
if (!disposed) {
|
||
syncFailed = true;
|
||
service.syncClosed.value = true;
|
||
if (!hasSnapshot) controller.addError(error, stack);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> start() async {
|
||
try {
|
||
final candidate = service.latestCacheCandidate;
|
||
final cached = candidate == null
|
||
? service.cachedSnapshot()
|
||
: (event: candidate, groups: service.decode(candidate));
|
||
if (cached != null) {
|
||
latest = cached.event;
|
||
hasSnapshot = true;
|
||
controller.add(cached.groups);
|
||
}
|
||
} catch (_) {
|
||
// A corrupt cache must not prevent a fresh relay read.
|
||
}
|
||
try {
|
||
service.check();
|
||
unsubscribe = await service.session.subscribe(
|
||
service.filter,
|
||
accept,
|
||
onClosed: (_) {
|
||
if (disposed) return;
|
||
subscriptionClosed = true;
|
||
service.syncClosed.value = true;
|
||
if (!hasSnapshot) {
|
||
controller.addError(
|
||
StateError('Couldn’t sync Solo. Reconnect and try again.'),
|
||
);
|
||
}
|
||
},
|
||
);
|
||
if (disposed) {
|
||
unsubscribe?.call();
|
||
return;
|
||
}
|
||
await refresh();
|
||
if (!disposed && !subscriptionClosed && !syncFailed) {
|
||
service.syncClosed.value = false;
|
||
}
|
||
} catch (error, stack) {
|
||
if (!disposed) {
|
||
syncFailed = true;
|
||
service.syncClosed.value = true;
|
||
if (!hasSnapshot) controller.addError(error, stack);
|
||
}
|
||
}
|
||
}
|
||
|
||
ref.listen(appLifecycleProvider, (_, next) {
|
||
if (next == AppLifecycleState.resumed) unawaited(refresh());
|
||
});
|
||
ref.onDispose(() {
|
||
disposed = true;
|
||
unsubscribe?.call();
|
||
unawaited(controller.close());
|
||
});
|
||
unawaited(start());
|
||
return controller.stream;
|
||
}, retry: (_, _) => null);
|
||
|
||
/// Non-disruptive offline-save status, retained across reader retries.
|
||
final meOfflineSaveWarningProvider = StreamProvider<bool>((ref) {
|
||
final service = ref.watch(meSyncProvider);
|
||
final controller = StreamController<bool>();
|
||
void changed() => controller.add(service.cacheWriteFailed.value);
|
||
service.cacheWriteFailed.addListener(changed);
|
||
changed();
|
||
ref.onDispose(() {
|
||
service.cacheWriteFailed.removeListener(changed);
|
||
unawaited(controller.close());
|
||
});
|
||
return controller.stream;
|
||
});
|
||
|
||
/// Retryable live-sync interruption without hiding cached organization.
|
||
final meSyncWarningProvider = StreamProvider<bool>((ref) {
|
||
final service = ref.watch(meSyncProvider);
|
||
final controller = StreamController<bool>();
|
||
void changed() => controller.add(service.syncClosed.value);
|
||
service.syncClosed.addListener(changed);
|
||
changed();
|
||
ref.onDispose(() {
|
||
service.syncClosed.removeListener(changed);
|
||
unawaited(controller.close());
|
||
});
|
||
return controller.stream;
|
||
});
|
||
|
||
/// NIP-01 replacement ordering: smallest ID wins an equal timestamp.
|
||
bool meEventIsNewer(NostrEvent a, NostrEvent b) =>
|
||
a.createdAt > b.createdAt ||
|
||
(a.createdAt == b.createdAt && a.id.compareTo(b.id) < 0);
|
||
|
||
final Provider<MeSync> meSyncProvider = Provider<MeSync>((ref) {
|
||
final config = ref.watch(relayConfigProvider);
|
||
final service = MeSync(
|
||
ref.read(relaySessionProvider.notifier),
|
||
config,
|
||
connectionState: () =>
|
||
ref.read(relaySessionProvider).status == SessionStatus.connected,
|
||
prefs: ref.read(savedPrefsProvider),
|
||
);
|
||
ref.onDispose(() => service.disposed = true);
|
||
return service;
|
||
});
|
||
|
||
/// Narrow private-recipe transport; never writes Messages organization.
|
||
class MeSync {
|
||
final RelaySessionNotifier session;
|
||
final RelayConfig config;
|
||
late final String community = Uri.parse(config.baseUrl).origin;
|
||
late final String privateHex = nostr.Nip19.decode(payload: config.nsec!).data;
|
||
late final String pubkey = nostr.Keys(privateHex).public;
|
||
late final key = getConversationKey(privateHex, pubkey);
|
||
final bool _connected;
|
||
|
||
/// Live transport status without replacing account/community-scoped state.
|
||
final bool Function()? connectionState;
|
||
|
||
/// Whether relay operations can currently be attempted.
|
||
bool get connected => connectionState?.call() ?? _connected;
|
||
final SharedPreferences? prefs;
|
||
String get pendingKey => 'me-pending:$community:$pubkey';
|
||
bool disposed = false;
|
||
bool _saving = false;
|
||
|
||
MeSync(
|
||
this.session,
|
||
this.config, {
|
||
bool connected = true,
|
||
this.connectionState,
|
||
this.prefs,
|
||
}) : _connected = connected;
|
||
|
||
/// Encrypted last verified organization, isolated by account and community.
|
||
String get cacheKey => 'me-cache:$community:$pubkey';
|
||
|
||
/// Read while offline. A signed event preserves replacement ordering.
|
||
({NostrEvent? event, MeGroups groups})? cachedSnapshot() {
|
||
if (disposed || config.nsec == null) return null;
|
||
final saved = prefs?.getString(cacheKey);
|
||
if (saved == null) return null;
|
||
final value = jsonDecode(nip44Decrypt(key, saved)) as Map<String, dynamic>;
|
||
if (value['community'] != community) {
|
||
throw const FormatException('Invalid cached organization');
|
||
}
|
||
final json = value['event'] as Map<String, dynamic>?;
|
||
final event = json == null ? null : NostrEvent.fromJson(json);
|
||
return (
|
||
event: event,
|
||
groups: event == null ? const MeGroups() : decode(event),
|
||
);
|
||
}
|
||
|
||
Future<void> _cacheQueue = Future.value();
|
||
bool _cacheDirty = false;
|
||
NostrEvent? _latestCacheCandidate;
|
||
|
||
/// Highest verified candidate survives a reader retry after failed storage.
|
||
NostrEvent? get latestCacheCandidate => _latestCacheCandidate;
|
||
|
||
/// False only after the newest candidate is durably written.
|
||
final cacheWriteFailed = ValueNotifier(false);
|
||
|
||
/// A terminal close or invalid relay head requires explicit reader recovery.
|
||
final syncClosed = ValueNotifier(false);
|
||
|
||
/// Serialize writes and report failures so callers can expose a retry.
|
||
Future<void> cache(NostrEvent? event) {
|
||
if (event != null) decode(event);
|
||
var highest = _latestCacheCandidate;
|
||
try {
|
||
final persisted = cachedSnapshot()?.event;
|
||
if (persisted != null &&
|
||
(highest == null || meEventIsNewer(persisted, highest))) {
|
||
highest = persisted;
|
||
}
|
||
} catch (_) {
|
||
// Verified relay data can replace an unreadable cache.
|
||
}
|
||
if (highest != null &&
|
||
(event == null ||
|
||
(highest.id != event.id && !meEventIsNewer(event, highest)))) {
|
||
_latestCacheCandidate = highest;
|
||
return Future.value();
|
||
}
|
||
_latestCacheCandidate = event;
|
||
final operation = _cacheQueue.then((_) => _writeCache(event));
|
||
_cacheQueue = operation.catchError((Object _) {});
|
||
return operation;
|
||
}
|
||
|
||
Future<void> _writeCache(NostrEvent? event) async {
|
||
if (disposed || config.nsec == null || prefs == null) return;
|
||
final highest = _latestCacheCandidate;
|
||
if (highest != null && highest.id != event?.id) return;
|
||
if (event != null) decode(event);
|
||
try {
|
||
final previous = cachedSnapshot()?.event;
|
||
if (!_cacheDirty &&
|
||
previous != null &&
|
||
(event == null || !meEventIsNewer(event, previous))) {
|
||
return;
|
||
}
|
||
} catch (_) {
|
||
// Replace corrupt local storage with verified relay data.
|
||
}
|
||
try {
|
||
if (!await prefs!.setString(
|
||
cacheKey,
|
||
nip44Encrypt(
|
||
key,
|
||
jsonEncode({'community': community, 'event': event?.toJson()}),
|
||
),
|
||
)) {
|
||
throw const MeCacheException();
|
||
}
|
||
_cacheDirty = false;
|
||
if (_latestCacheCandidate?.id == event?.id) {
|
||
cacheWriteFailed.value = false;
|
||
}
|
||
} catch (_) {
|
||
// SharedPreferences updates its memory cache even if the disk write fails.
|
||
// Reload so Retry cannot mistake that optimistic value for durable data.
|
||
_cacheDirty = true;
|
||
if (_latestCacheCandidate?.id == event?.id) cacheWriteFailed.value = true;
|
||
try {
|
||
await prefs!.reload();
|
||
} finally {
|
||
throw const MeCacheException();
|
||
}
|
||
}
|
||
}
|
||
|
||
NostrFilter get filter => NostrFilter(
|
||
kinds: const [30078],
|
||
authors: [pubkey],
|
||
tags: {
|
||
'#d': [MeGroups.coordinate(community)],
|
||
},
|
||
limit: 1,
|
||
);
|
||
|
||
void check() {
|
||
if (disposed) throw StateError('Your community changed. Open Solo again.');
|
||
if (!connected || config.nsec == null) {
|
||
throw StateError('Connect to your community to load Solo.');
|
||
}
|
||
}
|
||
|
||
MeGroups decode(NostrEvent event) {
|
||
if (event.kind != 30078 ||
|
||
event.pubkey != pubkey ||
|
||
event.tags.where((t) => t.isNotEmpty && t[0] == 'd').length != 1 ||
|
||
!event.tags.any(
|
||
(t) =>
|
||
t.length == 2 &&
|
||
t[0] == 'd' &&
|
||
t[1] == MeGroups.coordinate(community),
|
||
) ||
|
||
!event.tags.any(
|
||
(t) => t.length == 2 && t[0] == 't' && t[1] == MeGroups.tag,
|
||
)) {
|
||
throw const FormatException('Invalid Solo record');
|
||
}
|
||
nostr.Event.fromJson(jsonEncode(event.toJson()));
|
||
return MeGroups.decode(nip44Decrypt(key, event.content), community);
|
||
}
|
||
|
||
Future<NostrEvent?> head() async {
|
||
check();
|
||
final events = await session.fetchHistory(filter);
|
||
check();
|
||
if (events.isEmpty) return null;
|
||
final event = events.reduce((a, b) => meEventIsNewer(a, b) ? a : b);
|
||
decode(event);
|
||
return event;
|
||
}
|
||
|
||
/// Discard only the pending snapshot the user explicitly reviewed.
|
||
Future<void> discardPending(String expected) async {
|
||
check();
|
||
if (_saving || prefs?.getString(pendingKey) != expected) {
|
||
throw StateError('The pending save changed. Review it again.');
|
||
}
|
||
await clearMeJournal(prefs!, pendingKey, expected, checkScope: check);
|
||
}
|
||
|
||
Future<void> _clearConfirmedPending(String expected) async {
|
||
final storage = prefs;
|
||
if (storage == null) return;
|
||
await storage.reload();
|
||
check();
|
||
final current = storage.getString(pendingKey);
|
||
if (current == null) return;
|
||
if (current != expected) {
|
||
throw StateError('The pending save changed. Review it again.');
|
||
}
|
||
try {
|
||
if (!await storage.remove(pendingKey)) {
|
||
throw StateError('Local cleanup failed.');
|
||
}
|
||
} catch (_) {
|
||
// remove() mutates the in-memory preferences even when disk removal fails.
|
||
await storage.reload();
|
||
throw StateError(
|
||
'Saved to your community, but cleanup on this phone failed. Retry to finish the pending save.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Refresh before applying an intent, then confirm the selected signed event.
|
||
Future<void> update(MeGroups Function(MeGroups) change) async {
|
||
check();
|
||
if (_saving) throw StateError('A Solo change is still being saved.');
|
||
_saving = true;
|
||
try {
|
||
MeGroups? recovered;
|
||
String? recoveredHead;
|
||
final pending = prefs?.getString(pendingKey);
|
||
if (pending != null) {
|
||
final stored = jsonDecode(pending) as Map<String, dynamic>;
|
||
final event = NostrEvent.fromJson(
|
||
(stored['event'] as Map<String, dynamic>?) ?? stored,
|
||
);
|
||
final local = decode(event);
|
||
final remote = await head();
|
||
if (remote != null &&
|
||
remote.id != event.id &&
|
||
meEventIsNewer(remote, event)) {
|
||
if (!stored.containsKey('event')) {
|
||
// Older clients did not persist a base, so a safe rebase is unknown.
|
||
throw MeRecoveryConflict();
|
||
}
|
||
recoveredHead = remote.id;
|
||
final baseEvent = stored['base'] as Map<String, dynamic>?;
|
||
recovered = rebaseMeGroups(
|
||
baseEvent == null
|
||
? const MeGroups()
|
||
: decode(NostrEvent.fromJson(baseEvent)),
|
||
local,
|
||
decode(remote),
|
||
);
|
||
} else {
|
||
if (remote?.id != event.id) {
|
||
check();
|
||
await session.publish(event);
|
||
if ((await head())?.id != event.id) {
|
||
throw StateError(
|
||
'A previous Solo save is still unconfirmed. Try again.',
|
||
);
|
||
}
|
||
}
|
||
await _clearConfirmedPending(pending);
|
||
throw StateError(
|
||
'Your previous Solo save is confirmed. Review your projects before making another change.',
|
||
);
|
||
}
|
||
}
|
||
final before = await head();
|
||
if (recovered != null && before?.id != recoveredHead) {
|
||
throw StateError(
|
||
'Solo changed again. Your pending change is saved; try again.',
|
||
);
|
||
}
|
||
final value =
|
||
recovered ??
|
||
change(before == null ? const MeGroups() : decode(before));
|
||
final String encoded;
|
||
try {
|
||
encoded = value.encode(community);
|
||
} on FormatException {
|
||
if (recovered != null) throw MeRecoveryConflict();
|
||
rethrow;
|
||
}
|
||
final content = nip44Encrypt(key, encoded);
|
||
final current = await head();
|
||
if (before?.id != current?.id) {
|
||
throw StateError(
|
||
'Solo changed on another device. Refresh and try again.',
|
||
);
|
||
}
|
||
if (current != null &&
|
||
current.createdAt >= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
|
||
throw StateError('Please wait a second before saving again.');
|
||
}
|
||
check();
|
||
final signed = NostrEvent.fromJson(
|
||
nostr.Event.from(
|
||
kind: 30078,
|
||
content: content,
|
||
secretKey: privateHex,
|
||
verify: false,
|
||
tags: [
|
||
['d', MeGroups.coordinate(community)],
|
||
['t', MeGroups.tag],
|
||
],
|
||
).toMap(),
|
||
);
|
||
final pendingRecord = jsonEncode({
|
||
'event': signed.toJson(),
|
||
'base': before?.toJson(),
|
||
});
|
||
if (prefs != null && !await prefs!.setString(pendingKey, pendingRecord)) {
|
||
throw StateError('Couldn’t save this change on your phone. Try again.');
|
||
}
|
||
check();
|
||
await session.publish(signed);
|
||
check();
|
||
if ((await head())?.id != signed.id) {
|
||
throw StateError(
|
||
'Another Solo change is selected. Refresh to see the latest projects.',
|
||
);
|
||
}
|
||
await _clearConfirmedPending(pendingRecord);
|
||
if (recovered != null) {
|
||
throw StateError(
|
||
'Your previous Solo save was recovered alongside the desktop changes. Review your projects before making another change.',
|
||
);
|
||
}
|
||
} finally {
|
||
_saving = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A verified Solo snapshot could not be retained for offline use.
|
||
class MeCacheException implements Exception {
|
||
const MeCacheException();
|
||
@override
|
||
String toString() => 'Could not save Solo for offline use. Please retry.';
|
||
}
|