Skip to content

Commit 460dc35

Browse files
wesbillmanCarlclaude
committed
fix(mobile): reattach a chat rebuilt while detached
Riverpod defers rebuilding a provider nobody listens to until it is listened to or read again, and skips onResume after that rebuild. A chat detached after 30 s that then saw a session change, such as a reconnect after backgrounding, stayed detached when reopened: no live messages, typing or catch-up. A build now always clears the detached state, and the queues' listener flag with it. Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Wes <[email protected]>
1 parent da5077d commit 460dc35

3 files changed

Lines changed: 37 additions & 7 deletions

File tree

‎mobile/lib/features/channels/channel_messages_provider.dart‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
123123
@override
124124
AsyncValue<List<NostrEvent>> build() {
125125
final sessionState = ref.watch(relaySessionProvider);
126+
// Riverpod defers rebuilding a provider nobody is listening to until it
127+
// is listened to or read again, and then skips onResume. So a build
128+
// always means the chat is wanted.
129+
_hasListeners = true;
130+
_liveDetached = false;
126131
ref.onCancel(() {
127132
_hasListeners = false;
128133
_deletionOwnerQueue.pause();
@@ -155,7 +160,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
155160
_clearSubscription();
156161
});
157162

158-
if (sessionState.status != SessionStatus.connected || _liveDetached) {
163+
if (sessionState.status != SessionStatus.connected) {
159164
_initVersion++;
160165
_initInFlight = false;
161166
_initialWindowQueryInFlight = false;

‎mobile/lib/features/channels/channel_typing_provider.dart‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ class ChannelTypingNotifier extends Notifier<List<TypingEntry>> {
4747
final sessionState = ref.watch(relaySessionProvider);
4848
final generation = ++_generation;
4949
_activity.clear();
50+
// Riverpod defers rebuilding a provider nobody is listening to until it
51+
// is listened to or read again, and then skips onResume. So a build
52+
// always means the channel is wanted.
53+
_detached = false;
5054

5155
ref.onCancel(() {
5256
_detachTimer?.cancel();
@@ -73,7 +77,7 @@ class ChannelTypingNotifier extends Notifier<List<TypingEntry>> {
7377
_stop();
7478
});
7579

76-
if (sessionState.status == SessionStatus.connected && !_detached) {
80+
if (sessionState.status == SessionStatus.connected) {
7781
unawaited(_subscribeLive(generation));
7882
}
7983

‎mobile/test/features/channels/channel_messages_journey_test.dart‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:buzz/features/channels/channel_messages_provider.dart';
2+
import 'package:buzz/features/channels/channel_typing_provider.dart';
23
import 'package:buzz/features/channels/pending_local_messages_provider.dart';
34
import 'package:buzz/features/channels/send_message_provider.dart';
45
import 'package:buzz/shared/relay/relay.dart';
@@ -128,23 +129,43 @@ void main() {
128129
expect(timeline(), contains('after reconnect'));
129130
expect(timeline().where((text) => text == 'hello'), hasLength(1));
130131

131-
// A chat left for a while stops following live; returning shows the
132-
// cached rows at once and catches up on what arrived meanwhile.
133-
bool followsChannel() => relay.liveFilters.any(
132+
// A chat left for a while stops following live, typing included;
133+
// returning shows the cached rows at once and catches up on what
134+
// arrived meanwhile.
135+
bool follows({required bool typing}) => relay.liveFilters.any(
134136
(filters) => filters.any(
135-
(filter) => (filter['#h'] as List?)?.contains(_channel) ?? false,
137+
(filter) =>
138+
((filter['#h'] as List?)?.contains(_channel) ?? false) &&
139+
(filter['kinds'] as List).contains(EventKind.typingIndicator) ==
140+
typing,
136141
),
137142
);
143+
bool followsChannel() =>
144+
follows(typing: false) && follows(typing: true);
145+
bool followsNothing() =>
146+
!follows(typing: false) && !follows(typing: true);
147+
var typing = container.listen(
148+
channelTypingProvider(_channel),
149+
(_, _) {},
150+
);
151+
settle();
152+
expect(followsChannel(), isTrue);
138153
messages.close();
154+
typing.close();
139155
async.elapse(const Duration(seconds: 29));
140156
expect(followsChannel(), isTrue, reason: 'a brief cover keeps it');
141157
async.elapse(const Duration(seconds: 2));
142-
expect(followsChannel(), isFalse);
158+
expect(followsNothing(), isTrue);
159+
// A reconnect while it's hidden must not strand it detached.
160+
relay.drop();
161+
async.elapse(const Duration(seconds: 2));
162+
expect(followsNothing(), isTrue);
143163
relay.deliver(message('while away', liveSince() + 1));
144164
messages = container.listen(
145165
channelMessagesProvider(_channel),
146166
(_, _) {},
147167
);
168+
typing = container.listen(channelTypingProvider(_channel), (_, _) {});
148169
expect(timeline(), contains('after reconnect'));
149170
settle();
150171
expect(followsChannel(), isTrue);

0 commit comments

Comments
 (0)