diff --git a/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart b/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart index 74b2322e34a..be8b9d8e58e 100644 --- a/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart +++ b/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart @@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../shared/text/truncate.dart'; import '../../../shared/theme/theme.dart'; import 'observer_models.dart'; @@ -245,9 +246,7 @@ class _MetadataItemWidget extends HookWidget { if (section.body.isNotEmpty) ...[ const SizedBox(height: Grid.quarter), Text( - section.body.length > 500 - ? '${section.body.substring(0, 500)}\u2026' - : section.body, + truncateWithEllipsis(section.body, 500, '\u2026'), style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -401,9 +400,11 @@ class _ToolItemWidget extends HookWidget { if (resultExpanded.value) ...[ const SizedBox(height: Grid.quarter), _CodeBlock( - text: item.result.length > 2000 - ? '${item.result.substring(0, 2000)}\n\n\u2026 (truncated)' - : item.result, + text: truncateWithEllipsis( + item.result, + 2000, + '\n\n\u2026 (truncated)', + ), isError: item.isError, ), ], diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 453c9e8ef3b..ef83f35fe38 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/text/truncate.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/modal_presentation.dart'; @@ -85,9 +86,7 @@ class ForumPostCard extends HookConsumerWidget { directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), agentMentionPubkeys: agentMentionPubkeys, ); - final preview = post.content.length > 200 - ? '${post.content.substring(0, 200)}...' - : post.content; + final preview = truncateWithEllipsis(post.content, 200, '...'); final summary = post.threadSummary; return GestureDetector( diff --git a/mobile/lib/shared/text/truncate.dart b/mobile/lib/shared/text/truncate.dart new file mode 100644 index 00000000000..63f8be90474 --- /dev/null +++ b/mobile/lib/shared/text/truncate.dart @@ -0,0 +1,37 @@ +/// Truncation for text that is shown to a person. +library; + +import 'package:characters/characters.dart'; + +/// Returns [text] shortened to at most [maxCharacters] user-perceived +/// characters, or [text] unchanged when it is already that short. +/// +/// `substring` and `length` count UTF-16 code units. Anything outside the +/// Basic Multilingual Plane — every emoji, and CJK Extension B — is stored as +/// a surrogate pair, so a cut that lands inside one leaves a lone surrogate: +/// not a character, and drawn as `\u{FFFD}` at the end of the preview. +/// +/// Counting characters also means the decision to truncate and the cut itself +/// agree with each other, and with what the reader sees. +/// +/// The reminder preview in `features/channels/message_actions.dart` already +/// does this with `.characters.take(...)`; this is the same rule, shared. +String truncateToCharacters(String text, int maxCharacters) { + if (maxCharacters <= 0) return ''; + final characters = text.characters; + if (characters.length <= maxCharacters) return text; + return characters.take(maxCharacters).toString(); +} + +/// Returns [text] with [ellipsis] appended when it had to be shortened to +/// [maxCharacters], or [text] unchanged when it fits. +/// +/// Callers used to spell this out as +/// `text.length > n ? '${text.substring(0, n)}…' : text`, which counts code +/// units twice over — once to decide, once to cut. +String truncateWithEllipsis(String text, int maxCharacters, String ellipsis) { + final truncated = truncateToCharacters(text, maxCharacters); + // `truncateToCharacters` returns `text` itself when it fits, so comparing + // lengths is exact and avoids walking the string again. + return truncated.length == text.length ? text : '$truncated$ellipsis'; +} diff --git a/mobile/test/shared/text/truncate_test.dart b/mobile/test/shared/text/truncate_test.dart new file mode 100644 index 00000000000..d8ee00572fd --- /dev/null +++ b/mobile/test/shared/text/truncate_test.dart @@ -0,0 +1,39 @@ +import 'package:buzz/shared/text/truncate.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('leaves text that is already short enough', () { + expect(truncateToCharacters('hello', 100), 'hello'); + expect(truncateToCharacters('hello', 5), 'hello'); + }); + + test('never ends on half a surrogate pair', () { + final body = '${'x' * 199}\u{1F389} more text'; + final cut = truncateToCharacters(body, 200); + expect(cut, '${'x' * 199}\u{1F389}'); + // A lone surrogate would leave the last code unit in D800..DBFF. + expect(cut.codeUnitAt(cut.length - 1) & 0xFC00 == 0xD800, isFalse); + }); + + test('counts characters, not code units', () { + const party = '\u{1F389}'; + expect(truncateToCharacters(party * 3, 2), party * 2); + expect(truncateToCharacters(party * 3, 2).runes.length, 2); + }); + + test('handles the degenerate limits', () { + expect(truncateToCharacters('hello', 0), ''); + expect(truncateToCharacters('', 10), ''); + }); + + test('appends the ellipsis only when it actually cut', () { + expect(truncateWithEllipsis('hello', 100, '...'), 'hello'); + expect(truncateWithEllipsis('hello', 5, '...'), 'hello'); + expect(truncateWithEllipsis('hello there', 5, '...'), 'hello...'); + }); + + test('does not cut the ellipsis into an emoji', () { + final body = '${'x' * 199}\u{1F389} more text'; + expect(truncateWithEllipsis(body, 200, '...'), '${'x' * 199}\u{1F389}...'); + }); +}