mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
## Summary In the mobile thread view, if a reply was edited before you opened the thread, the reply showed its original text and no "(edited)" marker. An edit that arrived while the thread was open worked. The thread `/query` asked only for message kinds. The channel window carries edits, reactions and deletions (aux events) only for top-level messages, so an earlier edit to a reply was in neither result. The thread query now sends `include_aux: true`, as desktop does (`buzz-app` `src/features/relay/threads.ts`). The relay already supports this on the `depth_limit` thread route: it returns the aux events for the root and every reply, plus deletions of those (`crates/buzz-relay/src/api/bridge.rs`, `bridge_thread_aux`). A relay that ignores the flag gives the same result as before. The relay sends a page's rows (at most 200 reply-marked events, of any kind) before that page's aux events. It also repeats the root's aux on every page. So `fetchCompleteThread` treats the leading run of events reply-marked into this thread, other than reactions and NIP-09 deletions, as the page's rows. Only rows count toward a page, move the cursor and feed the reply cache. A reply-marked huddle event still fills a page, and a reply-marked reaction never becomes a reply. A reply-marked edit of the root in the repeated aux can follow a short final page's rows and look like a full page, so the scan also stops when the next cursor does not advance. The thread-summary scan (`fetchCompleteThreadReplies`) still asks for replies only and pages exactly as on `main`. ### Related issue Found during the emulator testing of https://github.com/block/buzz/pull/8863. ### Testing - `just mobile-check`, `just file-size-check` and `just mobile-test` pass. - `thread_replies_provider_test.dart` adds two tests. The first checks that the query sends `include_aux` and that an edit made before the thread opens folds into its reply. The second covers pagination: a reply-marked huddle row filling a page, a reply-marked edit that is a row on one page and aux on the others, a reply-marked reaction that stays aux, 200 reply-marked reactions after 199 replies (one query), 200 reply-marked root edits after a short final page (the progress guard ends the scan), an edit reply-marked into another thread that stays aux, and that the summary scan doesn't send `include_aux`. It fails if any of these is removed: the reaction/deletion exclusion, the root check, the 200 cap, the progress guard, or the separate row and aux id sets. - `channel_detail_page_test.dart` adds a journey: it opens `ThreadDetailPage` with the real thread query and a fake relay session that sends the reply's earlier edit only when `include_aux` is set. It checks the edited text, that the original text is gone, and the marker. It fails when the flag is removed or when the provider drops aux. - Android emulator (AVD, debug build) against a local relay. Morty posts "Who can review the release notes?", Demo User replies "I can, after lunhc" and then edits the reply to "I can, after lunch", all before the thread is opened. These shots come from the first revision of this branch. The pagination changes after it give the same result for a one-page thread like this one. | Before (unfixed: the #8863 build) | After (this branch) | Live edit while the thread is open | | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-before.png" width="300" alt="Before: original reply text, no marker"> | <img src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-after.png" width="300" alt="After: edited reply text with (edited)"> | <img src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-live.png" width="300" alt="Live edit while the thread is open"> | Before, the thread shows the original reply text with no marker. After, it shows the edited text and "(edited)". A live edit still lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
8.1 KiB
Dart
264 lines
8.1 KiB
Dart
part of 'channel_detail_page_test.dart';
|
||
|
||
void threadReplyRefreshTests() {
|
||
const args = ThreadRepliesArgs(
|
||
channelId: _channelId,
|
||
rootId: threadIdRefreshDashRoot,
|
||
);
|
||
final root = _textMsg(
|
||
id: args.rootId,
|
||
pubkey: 'alice',
|
||
content: 'Refresh root',
|
||
);
|
||
final reply = _textMsg(
|
||
id: 'cached-reply',
|
||
pubkey: 'bob',
|
||
content: 'Cached reply',
|
||
createdAt: 1001,
|
||
extraTags: [
|
||
['e', args.rootId, '', 'reply'],
|
||
],
|
||
);
|
||
|
||
Future<(NavigatorState, ProviderContainer)> mount(
|
||
WidgetTester tester,
|
||
Future<List<NostrEvent>> Function() load, {
|
||
bool retry = false,
|
||
TextScaler textScaler = TextScaler.noScaling,
|
||
}) async {
|
||
await tester.pumpWidget(
|
||
_buildTestable(
|
||
messages: [root],
|
||
textScaler: textScaler,
|
||
threadReplyLoaders: {args.rootId: load},
|
||
providerRetry: (_, _) =>
|
||
retry ? const Duration(milliseconds: 200) : null,
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
final context = tester.element(find.byType(ChannelDetailPage));
|
||
return (Navigator.of(context), ProviderScope.containerOf(context));
|
||
}
|
||
|
||
void open(NavigatorState navigator) {
|
||
final head = formatTimeline([root]).single;
|
||
navigator.push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => ThreadDetailPage(
|
||
threadHead: head,
|
||
allMessages: [head],
|
||
channelId: _channelId,
|
||
currentPubkey: 'self',
|
||
isMember: true,
|
||
isArchived: false,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
testWidgets(
|
||
'thread refresh keeps cached replies through retry and recovery',
|
||
(tester) async {
|
||
var calls = 0;
|
||
final refresh = Completer<List<NostrEvent>>();
|
||
final retry = Completer<List<NostrEvent>>();
|
||
final (navigator, container) = await mount(tester, () {
|
||
calls++;
|
||
return switch (calls) {
|
||
1 => Future.value([reply]),
|
||
2 => refresh.future,
|
||
_ => retry.future,
|
||
};
|
||
}, retry: true);
|
||
open(navigator);
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('1 reply'), findsOneWidget);
|
||
container.invalidate(threadRepliesProvider(args));
|
||
await tester.pump();
|
||
expect(find.text('1 reply'), findsOneWidget);
|
||
refresh.completeError(Exception('Transient refresh failure'));
|
||
await tester.pump();
|
||
for (var frame = 0; frame < 20; frame++) {
|
||
await tester.pump(const Duration(milliseconds: 16));
|
||
expect(find.text('0 replies'), findsNothing, reason: 'frame $frame');
|
||
expect(
|
||
find.byKey(const ValueKey('thread-message-group-cached-reply')),
|
||
findsOneWidget,
|
||
);
|
||
expect(container.read(threadRepliesProvider(args)).value, [reply]);
|
||
}
|
||
expect(calls, 3);
|
||
retry.complete([
|
||
reply,
|
||
_textMsg(
|
||
id: 'fresh-reply',
|
||
pubkey: 'bob',
|
||
content: 'Fresh reply',
|
||
createdAt: 1002,
|
||
extraTags: [
|
||
['e', args.rootId, '', 'reply'],
|
||
],
|
||
),
|
||
]);
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('2 replies'), findsOneWidget);
|
||
expect(
|
||
find.byKey(const ValueKey('thread-message-group-fresh-reply')),
|
||
findsOneWidget,
|
||
);
|
||
},
|
||
);
|
||
|
||
testWidgets('thread refresh error keeps cached replies visible', (
|
||
tester,
|
||
) async {
|
||
final semantics = tester.ensureSemantics();
|
||
var calls = 0;
|
||
final refresh = Completer<List<NostrEvent>>();
|
||
tester.view.physicalSize = const Size(320, 844);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.resetPhysicalSize);
|
||
addTearDown(tester.view.resetDevicePixelRatio);
|
||
final (navigator, container) = await mount(
|
||
tester,
|
||
() => ++calls == 1 ? Future.value([reply]) : refresh.future,
|
||
textScaler: const TextScaler.linear(2),
|
||
);
|
||
open(navigator);
|
||
await tester.pumpAndSettle();
|
||
container.invalidate(threadRepliesProvider(args));
|
||
await tester.pump();
|
||
refresh.completeError(Exception('Refresh failed'));
|
||
await tester.pumpAndSettle();
|
||
expect(
|
||
find.byKey(const ValueKey('thread-message-group-cached-reply')),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.text('0 replies'), findsNothing);
|
||
expect(find.text('1 reply · Couldn’t refresh'), findsOneWidget);
|
||
expect(
|
||
tester
|
||
.getSemantics(find.text('1 reply · Couldn’t refresh'))
|
||
.getSemanticsData()
|
||
.flagsCollection
|
||
.isLiveRegion,
|
||
isTrue,
|
||
);
|
||
expect(tester.takeException(), isNull);
|
||
expect(container.read(threadRepliesProvider(args)).hasError, isTrue);
|
||
semantics.dispose();
|
||
});
|
||
|
||
testWidgets('thread reopen shows loading until the fresh query completes', (
|
||
tester,
|
||
) async {
|
||
var calls = 0;
|
||
final reopened = Completer<List<NostrEvent>>();
|
||
final (navigator, _) = await mount(
|
||
tester,
|
||
() => ++calls == 1 ? Future.value([reply]) : reopened.future,
|
||
);
|
||
open(navigator);
|
||
await tester.pumpAndSettle();
|
||
navigator.pop();
|
||
await tester.pumpAndSettle();
|
||
open(navigator);
|
||
await tester.pumpAndSettle();
|
||
expect(calls, 2);
|
||
expect(find.text('0 replies'), findsNothing);
|
||
expect(find.text('Loading replies…'), findsOneWidget);
|
||
reopened.complete([reply]);
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('1 reply'), findsOneWidget);
|
||
expect(find.text('Loading replies…'), findsNothing);
|
||
});
|
||
|
||
testWidgets('thread initial error is distinct from an empty thread', (
|
||
tester,
|
||
) async {
|
||
final query = Completer<List<NostrEvent>>();
|
||
final (navigator, _) = await mount(tester, () => query.future);
|
||
open(navigator);
|
||
await tester.pumpAndSettle();
|
||
query.completeError(Exception('Initial load failed'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('0 replies'), findsNothing);
|
||
expect(find.text('Couldn’t load replies'), findsOneWidget);
|
||
});
|
||
testWidgets('thread successful empty query displays zero replies', (
|
||
tester,
|
||
) async {
|
||
final (navigator, _) = await mount(tester, () async => []);
|
||
open(navigator);
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('0 replies'), findsOneWidget);
|
||
expect(find.text('Loading replies…'), findsNothing);
|
||
expect(find.text('Couldn’t load replies'), findsNothing);
|
||
});
|
||
testWidgets('thread loads an edit made to a reply before it opened', (
|
||
tester,
|
||
) async {
|
||
// The real thread query runs; the relay session is faked. It sends the
|
||
// reply's earlier edit only to a query that asks for aux events.
|
||
final edit = NostrEvent(
|
||
id: 'cached-reply-edit',
|
||
pubkey: 'bob',
|
||
createdAt: 1002,
|
||
kind: EventKind.streamMessageEdit,
|
||
tags: [
|
||
const ['h', _channelId],
|
||
['e', reply.id],
|
||
],
|
||
content: 'Fixed reply',
|
||
sig: '',
|
||
);
|
||
final session = _ThreadAuxRelaySession([reply], [edit]);
|
||
await tester.pumpWidget(
|
||
_buildTestable(messages: [root], relaySessionNotifier: session),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
open(Navigator.of(tester.element(find.byType(ChannelDetailPage))));
|
||
await tester.pumpAndSettle();
|
||
final thread = find.byType(ThreadDetailPage);
|
||
expect(
|
||
find.descendant(of: thread, matching: find.text('Fixed reply')),
|
||
findsOneWidget,
|
||
);
|
||
expect(
|
||
find.descendant(of: thread, matching: find.text('Cached reply')),
|
||
findsNothing,
|
||
);
|
||
expect(
|
||
find.descendant(of: thread, matching: find.text('(edited)')),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
}
|
||
|
||
class _ThreadAuxRelaySession extends RelaySessionNotifier {
|
||
_ThreadAuxRelaySession(this.replies, this.aux);
|
||
final List<NostrEvent> replies;
|
||
final List<NostrEvent> aux;
|
||
|
||
@override
|
||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||
|
||
@override
|
||
Future<List<NostrEvent>> queryRelay(
|
||
List<NostrFilter> filters, {
|
||
Duration timeout = const Duration(seconds: 8),
|
||
}) async => [
|
||
for (final filter in filters)
|
||
if (filter.extensions.containsKey('depth_limit')) ...[
|
||
...replies,
|
||
if (filter.extensions['include_aux'] == true) ...aux,
|
||
],
|
||
];
|
||
|
||
@override
|
||
Future<List<NostrEvent>> fetchHistory(
|
||
NostrFilter filter, {
|
||
Duration timeout = const Duration(seconds: 8),
|
||
}) async => const [];
|
||
}
|