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
101 changes: 99 additions & 2 deletions mobile/lib/features/channels/channel_messages_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../shared/relay/relay.dart';
import 'channel_management_provider.dart';
import 'pending_local_messages_provider.dart';
import 'channel_window.dart';
import 'thread_replies_provider.dart';

Expand Down Expand Up @@ -87,6 +88,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {

final history = await _fetchNewestHistory(session);
if (!_isCurrentInit(initVersion)) return;
_confirmLocalMessages(history.map((event) => event.id));

final existing = state.value ?? const <NostrEvent>[];
final existingIds = existing.map((event) => event.id).toSet();
Expand Down Expand Up @@ -159,7 +161,13 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
},
);

void _handleLiveEvent(NostrEvent event) {
void _handleLiveEvent(NostrEvent event, {bool authoritative = true}) {
// Reply ownership and its thread-local overlay must transition together.
// The authoritative thread query performs both confirmations after it
// contains the reply; a live echo only triggers that query below.
if (authoritative && event.threadReference.parentId == null) {
_confirmLocalMessages([event.id]);
}
if (_usingChannelWindow) {
_handleWindowLiveEvent(event);
} else {
Expand Down Expand Up @@ -223,19 +231,108 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
return true;
}

void _confirmLocalMessages(Iterable<String> eventIds) {
ref
.read(pendingLocalMessagesProvider(channelId).notifier)
.confirm(eventIds);
}

static bool _isMembershipEvent(String content) {
return content.contains('member_joined') ||
content.contains('member_left') ||
content.contains('member_removed');
}

/// Adds a just-signed outgoing message before the relay acknowledges it.
/// The live relay echo is deduplicated by event id.
void addLocalMessage(NostrEvent event) {
ref.read(pendingLocalMessagesProvider(channelId).notifier).add(event);
final thread = event.threadReference;
if (thread.parentId != null) {
final rootId = thread.rootId;
if (rootId == null) {
throw StateError('Reply ${event.id} has a parent but no thread root.');
}
ref
.read(
threadLocalRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
).notifier,
)
.add(event);
return;
}

final isTimelineRow = EventKind.channelTimelineContentKinds.contains(
event.kind,
);
if (!_usingChannelWindow && isTimelineRow) {
_windowStore = mergeLiveChannelWindowEvent(
_windowStore,
event,
isTimelineRow: true,
);
}
_handleLiveEvent(event, authoritative: false);
}

/// Releases rollback ownership after the publish future succeeds. The
/// optimistic row (and any thread overlay) remains visible until relay data
/// replaces it, because OK and EVENT delivery are unordered.
void completeLocalMessage(String eventId) {
_confirmLocalMessages([eventId]);
}

/// Rolls back a local message when its publish is rejected or times out.
void removeLocalMessage(String eventId) {
final pending = ref
.read(pendingLocalMessagesProvider(channelId).notifier)
.take(eventId);
if (pending == null) return;

final thread = pending.threadReference;
if (thread.parentId != null) {
final rootId = thread.rootId;
if (rootId == null) {
throw StateError('Reply $eventId has a parent but no thread root.');
}
ref
.read(
threadLocalRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
).notifier,
)
.remove(eventId);
return;
}

final nextOverlay = _windowStore.liveOverlay
.where((event) => event.id != eventId)
.toList();
if (nextOverlay.length != _windowStore.liveOverlay.length) {
_windowStore = ChannelWindowStore(
pages: _windowStore.pages,
liveOverlay: nextOverlay,
liveAux: _windowStore.liveAux,
);
}

final current = state.value ?? _lastKnownMessages ?? const <NostrEvent>[];
final next = current.where((event) => event.id != eventId).toList();
_lastKnownMessages = next;
state = AsyncData(next);
}

static List<NostrEvent> _mergeEvent(
List<NostrEvent> current,
NostrEvent incoming,
) {
if (current.any((e) => e.id == incoming.id)) return current;
final updated = [...current, incoming];
updated.sort((a, b) => a.createdAt.compareTo(b.createdAt));
updated.sort((a, b) {
final createdAt = a.createdAt.compareTo(b.createdAt);
return createdAt != 0 ? createdAt : a.id.compareTo(b.id);
});
return updated;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../shared/relay/relay.dart';

/// Signed local messages whose publish has not yet been corroborated by an
/// authoritative relay EVENT or query result.
class PendingLocalMessagesNotifier extends Notifier<Map<String, NostrEvent>> {
final String channelId;

PendingLocalMessagesNotifier(this.channelId);

@override
Map<String, NostrEvent> build() => const {};

void add(NostrEvent event) {
state = {...state, event.id: event};
}

NostrEvent? take(String eventId) {
final event = state[eventId];
if (event == null) return null;
final next = {...state}..remove(eventId);
state = next;
return event;
}

void confirm(Iterable<String> eventIds) {
final confirmed = eventIds.toSet();
if (!state.keys.any(confirmed.contains)) return;
state = {
for (final entry in state.entries)
if (!confirmed.contains(entry.key)) entry.key: entry.value,
};
}
}

final pendingLocalMessagesProvider =
NotifierProvider.family<
PendingLocalMessagesNotifier,
Map<String, NostrEvent>,
String
>(PendingLocalMessagesNotifier.new);
45 changes: 39 additions & 6 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,33 @@ import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_messages_provider.dart';

/// Sends messages by signing an event with the user's nsec and publishing it
/// over the relay's NIP-42-authenticated WebSocket session.
class SendMessage {
final SignedEventRelay _signedEventRelay;
final Future<List<ChannelMember>> Function(String channelId) _fetchMembers;
final Map<String, UserProfile> Function() _readUserCache;
final void Function(String channelId, NostrEvent event) _addLocalMessage;
final void Function(String channelId, String eventId) _completeLocalMessage;
final void Function(String channelId, String eventId) _removeLocalMessage;

SendMessage({
required SignedEventRelay signedEventRelay,
required Future<List<ChannelMember>> Function(String channelId)
fetchMembers,
required Map<String, UserProfile> Function() readUserCache,
required void Function(String channelId, NostrEvent event) addLocalMessage,
required void Function(String channelId, String eventId)
completeLocalMessage,
required void Function(String channelId, String eventId) removeLocalMessage,
}) : _signedEventRelay = signedEventRelay,
_fetchMembers = fetchMembers,
_readUserCache = readUserCache;
_readUserCache = readUserCache,
_addLocalMessage = addLocalMessage,
_completeLocalMessage = completeLocalMessage,
_removeLocalMessage = removeLocalMessage;

/// Send a text message to a channel.
///
Expand Down Expand Up @@ -58,11 +69,24 @@ class SendMessage {
...mediaTags,
];

await _signedEventRelay.submit(
kind: EventKind.streamMessage,
content: content,
tags: tags,
);
NostrEvent? localMessage;
try {
await _signedEventRelay.submit(
kind: EventKind.streamMessage,
content: content,
tags: tags,
onSigned: (event) {
localMessage = event;
_addLocalMessage(channelId, event);
},
);
final event = localMessage;
if (event != null) _completeLocalMessage(channelId, event.id);
} catch (_) {
final event = localMessage;
if (event != null) _removeLocalMessage(channelId, event.id);
rethrow;
}
}

/// Resolve @mentions to pubkeys, scoped to channel members.
Expand Down Expand Up @@ -146,5 +170,14 @@ final sendMessageProvider = Provider<SendMessage>((ref) {
fetchMembers: (channelId) =>
ref.read(channelMembersProvider(channelId).future),
readUserCache: () => ref.read(userCacheProvider),
addLocalMessage: (channelId, event) => ref
.read(channelMessagesProvider(channelId).notifier)
.addLocalMessage(event),
completeLocalMessage: (channelId, eventId) => ref
.read(channelMessagesProvider(channelId).notifier)
.completeLocalMessage(eventId),
removeLocalMessage: (channelId, eventId) => ref
.read(channelMessagesProvider(channelId).notifier)
.removeLocalMessage(eventId),
);
});
2 changes: 1 addition & 1 deletion mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class ThreadDetailPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final repliesState = ref.watch(
threadRepliesProvider(
threadRepliesWithLocalProvider(
ThreadRepliesArgs(channelId: channelId, rootId: threadHead.id),
),
);
Expand Down
77 changes: 77 additions & 0 deletions mobile/lib/features/channels/thread_replies_provider.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'dart:async';

import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../shared/relay/relay.dart';
import 'pending_local_messages_provider.dart';

class ThreadRepliesArgs {
final String channelId;
Expand Down Expand Up @@ -64,3 +67,77 @@ NostrFilter _threadRepliesFilter(
},
);
}

class ThreadLocalRepliesNotifier extends Notifier<List<NostrEvent>> {
final ThreadRepliesArgs args;

ThreadLocalRepliesNotifier(this.args);

@override
List<NostrEvent> build() => const [];

void add(NostrEvent event) {
state = _mergeReplies(state, [event]);
}

void remove(String eventId) {
state = state.where((event) => event.id != eventId).toList();
}

void confirm(Set<String> eventIds) {
if (!state.any((event) => eventIds.contains(event.id))) return;
state = state.where((event) => !eventIds.contains(event.id)).toList();
}
}

final threadLocalRepliesProvider =
NotifierProvider.family<
ThreadLocalRepliesNotifier,
List<NostrEvent>,
ThreadRepliesArgs
>(ThreadLocalRepliesNotifier.new);

/// Relay-backed replies merged with signed local replies that are still
/// waiting for acknowledgement.
final threadRepliesWithLocalProvider =
Provider.family<AsyncValue<List<NostrEvent>>, ThreadRepliesArgs>((
ref,
args,
) {
final relayReplies = ref.watch(threadRepliesProvider(args));
final localReplies = ref.watch(threadLocalRepliesProvider(args));
final authoritative = relayReplies.value;
if (authoritative != null && localReplies.isNotEmpty) {
final authoritativeIds = authoritative.map((event) => event.id).toSet();
if (localReplies.any((event) => authoritativeIds.contains(event.id))) {
Future.microtask(() {
ref
.read(threadLocalRepliesProvider(args).notifier)
.confirm(authoritativeIds);
ref
.read(pendingLocalMessagesProvider(args.channelId).notifier)
.confirm(authoritativeIds);
});
}
}
if (localReplies.isEmpty) return relayReplies;
return relayReplies.when(
data: (events) => AsyncData(_mergeReplies(events, localReplies)),
loading: () => AsyncData(localReplies),
error: (error, stackTrace) => AsyncData(localReplies),
);
});

List<NostrEvent> _mergeReplies(
Iterable<NostrEvent> first,
Iterable<NostrEvent> second,
) {
final byId = <String, NostrEvent>{};
for (final event in [...first, ...second]) {
byId[event.id] = event;
}
return byId.values.toList()..sort((a, b) {
final createdAt = a.createdAt.compareTo(b.createdAt);
return createdAt != 0 ? createdAt : a.id.compareTo(b.id);
});
}
2 changes: 2 additions & 0 deletions mobile/lib/shared/relay/signed_event_relay.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class SignedEventRelay {
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
Expand All @@ -52,6 +53,7 @@ class SignedEventRelay {
);

final nostrEvent = NostrEvent.fromJson(event.toMap());
onSigned?.call(nostrEvent);
return _session.publish(nostrEvent);
}
}
Loading
Loading