Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
),
],
Expand Down
5 changes: 2 additions & 3 deletions mobile/lib/features/forum/forum_post_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
37 changes: 37 additions & 0 deletions mobile/lib/shared/text/truncate.dart
Original file line number Diff line number Diff line change
@@ -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';
}
39 changes: 39 additions & 0 deletions mobile/test/shared/text/truncate_test.dart
Original file line number Diff line number Diff line change
@@ -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}...');
});
}