Files
buzz/mobile/test/features/channels/thread_reply_refresh_cases.dart
Logan Johnson 6384c6cdfa feat(mobile): mark edited messages after the body (#8863)
## Summary

Mobile showed "(edited)" only in the author header. A grouped follow-up
message has no header, so an edited follow-up had no marker at all. This
ports the placement from desktop
https://github.com/block/buzz-app/pull/2020 to the channel and thread
message bodies:

- **After prose:** the marker joins the last line, for example `Hi how
are ya. (edited)`. This includes a final list item or quote, as on
desktop. It is drawn in the timestamp style, with the metadata colour on
received messages and the bubble ink on your own messages.
- **After a block that text cannot join:** the marker goes on its own
line inside the same text bubble. This applies to code, tables, rules,
display math, indented code, a heading, a last line that ends in a link,
and a bare list, quote or checkbox marker. A link renders as a widget
span, so a joined marker would be read before the link by screen
readers. Any body that contains ```` ``` ```` always uses its own line.
gpt_markdown's fence rules differ from CommonMark, so putting the marker
inside the Markdown could change how the code renders.
- **After trailing images:** the marker goes below the gallery, outside
the bubble.
- The marker is injected only at render time: a private-use slot
character that the renderer removes from authored content first. Copy
text, previews and notifications still use the original message content.

**Out of scope:** the mobile forum models (`ForumPost`, `ThreadReply`)
have no edited state and don't fold edits, so forum posts still show no
marker. Search and inbox previews also show no marker, which matches the
preview rules.

### Related issue

Desktop parity for https://github.com/block/buzz-app/pull/2020. I found
no existing mobile issue or PR for this.

### Testing

At `5276d968`:

- `just mobile-check`, `just file-size-check` and `just mobile-test` all
exit 0.
- `edited_marker_test.dart`: a table test of which endings join inline
and which take their own line, including `` ```npm install``` ``, `~~~`,
indented code, tables, rules, math and bare block markers.
- `message_content_bubble_cases.dart`: rendered widget checks. For each
risky ending, the edited render is the unedited render plus one
`(edited)` line inside the same text bubble. The tests also cover
colours, trailing media, an open `<u>` and authored slot characters.
With the guards removed, this test fails.
- `channel_detail_page_test.dart`, edit journey: an edited message and
an edited grouped follow-up are marked and an unedited message isn't.
Copy text copies `Edited text` without the marker. Opening the thread
marks the edited head and the edited reply. With the thread wiring
removed, this test fails.
- Contrast (from review): received 5.34:1 light and 8.61:1 dark;
own-bubble accents at least 4.60:1.
- Android emulator run (screenshots below): every edited case shows the
marker where the rules above say, in light and dark. Not done: iOS
simulator and VoiceOver.
- Found during the emulator run, not caused by this PR: an edit made to
a thread reply before the thread is opened is not loaded, so the reply
shows its original text and no marker. The thread query asks only for
content kinds, and the channel history did not supply the earlier edit.
An edit that arrives while the thread is open shows correctly
(screenshot).

**Android emulator** (debug build of `5276d968` on a local relay, seeded
with edited messages):

| Prose, grouped follow-up, own bubbles, list | Code block, table,
fenced inline text, trailing image |
| - | - |
|
![emu-1-prose](https://raw.githubusercontent.com/block/buzz/673534d3aab0a87148655e19142965dbfdb6e3ef/pr-8863--emu-1-prose.png)
|
![emu-2-blocks](https://raw.githubusercontent.com/block/buzz/673534d3aab0a87148655e19142965dbfdb6e3ef/pr-8863--emu-2-blocks.png)
|

| Dark | Dark, trailing image | Thread (edit arrived while open) |
| - | - | - |
|
![emu-3-dark](https://raw.githubusercontent.com/block/buzz/673534d3aab0a87148655e19142965dbfdb6e3ef/pr-8863--emu-3-dark.png)
|
![emu-5-dark-media](https://raw.githubusercontent.com/block/buzz/673534d3aab0a87148655e19142965dbfdb6e3ef/pr-8863--emu-5-dark-media.png)
|
![emu-4-thread](https://raw.githubusercontent.com/block/buzz/673534d3aab0a87148655e19142965dbfdb6e3ef/pr-8863--emu-4-thread.png)
|

**Widget renders** (throwaway test, not committed):

| Light | Dark |
| - | - |
|
![edited-light](https://raw.githubusercontent.com/block/buzz/6a790edaaaa80acff4ed4a358be92e5ca8f86e25/pr-8863--edited-light.png)
|
![edited-dark](https://raw.githubusercontent.com/block/buzz/6a790edaaaa80acff4ed4a358be92e5ca8f86e25/pr-8863--edited-dark.png)
|

🤖 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>
2026-11-10 07:02:54 -08:00

263 lines
8.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: findRichText('Fixed reply (edited)'),
),
findsOneWidget,
);
expect(
find.descendant(of: thread, matching: findRichText('Cached reply')),
findsNothing,
);
});
}
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 [];
}