fix(mobile): bound long preview lines and label Buzz links

A single huge line no longer reaches preview preprocessing in full, and
message, repo, pull request and issue links preview with the same short
label as the thread view's chips, via a shared buzzLinkLabel helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-29 11:01:52 -07:00
parent f538853ed1
commit 8c721ba3a9
4 changed files with 88 additions and 31 deletions

View File

@@ -37,6 +37,7 @@ import 'message_content/link_normalizer.dart';
import 'message_content/preview_text.dart';
import 'message_media.dart';
import 'message_content/bubble_segments.dart';
import 'message_content/buzz_link_label.dart';
import 'message_mention_pill.dart';
import 'message_gallery.dart';
import 'message_gallery_frame.dart';
@@ -612,22 +613,21 @@ class MessageContent extends HookConsumerWidget {
buzzLink is MessageDeepLink ||
buzzLink is EntityDeepLink;
final isCanonicalBuzzLabel = isBuzzLink && text == url;
String? channelName(String id) =>
_channelNameForId(resolvedChannelNames, id);
final buzzLabel = buzzLinkLabel(buzzLink, channelName);
final buzzPresentation = switch (buzzLink) {
ChannelDeepLink(:final channelId) => (
ChannelDeepLink() => (
icon: BuzzIcons.hash,
label:
_channelNameForId(resolvedChannelNames, channelId) ??
channelId.substring(0, math.min(8, channelId.length)),
semanticLabel:
'Open channel ${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))}',
label: buzzLabel!,
semanticLabel: 'Open channel $buzzLabel',
interactive: true,
),
MessageDeepLink(:final channelId, :final messageId) => (
icon: BuzzIcons.messageSquare,
label:
'${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))} · ${messageId.substring(0, math.min(8, messageId.length))}',
label: buzzLabel!,
semanticLabel:
'Open message ${messageId.substring(0, math.min(8, messageId.length))} in channel ${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))}',
'Open message ${shortBuzzId(messageId)} in channel ${buzzChannelLabel(channelId, channelName)}',
interactive: true,
),
EntityDeepLink(:final type, :final repository, :final eventId) => (
@@ -636,14 +636,12 @@ class MessageContent extends HookConsumerWidget {
'pr' => BuzzIcons.gitPullRequest,
_ => BuzzIcons.circleDot,
},
label: type == 'repo'
? repository
: '$repository · ${eventId!.substring(0, 8)}',
label: buzzLabel!,
semanticLabel: switch (type) {
'repo' => 'Repository $repository',
'pr' =>
'Pull request ${eventId!.substring(0, 8)} in repository $repository',
_ => 'Issue ${eventId!.substring(0, 8)} in repository $repository',
'Pull request ${shortBuzzId(eventId ?? '')} in repository $repository',
_ => 'Issue ${shortBuzzId(eventId ?? '')} in repository $repository',
},
interactive: false,
),

View File

@@ -0,0 +1,28 @@
import 'dart:math' as math;
import '../../../shared/deeplink/deep_link.dart';
/// The short label a Buzz channel, message, repo, pull request or issue link
/// shows in place of its URL, or null for any other link. [channelName] gives
/// a channel's display name, or null when it is unknown.
String? buzzLinkLabel(
BuzzDeepLink? link,
String? Function(String channelId) channelName,
) => switch (link) {
ChannelDeepLink(:final channelId) => buzzChannelLabel(channelId, channelName),
MessageDeepLink(:final channelId, :final messageId) =>
'${buzzChannelLabel(channelId, channelName)} · ${shortBuzzId(messageId)}',
EntityDeepLink(type: 'repo', :final repository) => repository,
EntityDeepLink(:final repository, :final eventId) =>
'$repository · ${shortBuzzId(eventId ?? '')}',
_ => null,
};
/// [channelId]'s display name, or its first 8 characters when unknown.
String buzzChannelLabel(
String channelId,
String? Function(String channelId) channelName,
) => channelName(channelId) ?? shortBuzzId(channelId);
/// The first 8 characters of an id, as Buzz links show it.
String shortBuzzId(String id) => id.substring(0, math.min(8, id.length));

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import '../../../shared/deeplink/deep_link.dart';
import '../../../shared/mentions/mention_bindings.dart';
import '../message_media.dart';
import 'buzz_link_label.dart';
import 'link_normalizer.dart';
/// One piece of a message preview: plain text or an inline token that renders
@@ -159,13 +160,19 @@ Iterable<({String text, bool code, int end})> _keptLines(
while (start <= content.length &&
start <= _maxScanLength &&
kept <= maxLines) {
var end = content.indexOf('\n', start);
if (end < 0) end = content.length;
// A long line wraps past the visible lines well before this length.
var line = content
.substring(start, math.min(end, start + 2 * _maxLineLength))
.trim();
start = end + 1;
// A long line wraps past the visible lines well before this length, so
// reading stops there rather than at the line's real end.
var cap = math.min(content.length, start + 2 * _maxLineLength);
if (cap < content.length && _isHighSurrogate(content.codeUnitAt(cap - 1))) {
cap--;
}
var end = start;
while (end < cap && content.codeUnitAt(end) != _newline) {
end++;
}
final cut = end == cap && cap < content.length;
var line = content.substring(start, end).trim();
start = cut ? content.length + 1 : end + 1;
// A fence opens at any line starting with backticks and closes only at a
// line holding nothing else, as in gpt_markdown.
if (!fenceOpen && line.startsWith('```') || fenceOpen && line == '```') {
@@ -189,8 +196,11 @@ Iterable<({String text, bool code, int end})> _keptLines(
const _maxLineLength = 2000;
const _maxScanLength = 8 * _maxLineLength;
const _backslash = 0x5C;
const _newline = 0x0A;
const _backtick = 0x60;
bool _isHighSurrogate(int unit) => unit >= 0xD800 && unit <= 0xDBFF;
final _rule = RegExp(r'^([-*_])(?:\s*\1){2,}$');
final _tableDivider = RegExp(r'^\|?(?:\s*:?-+:?\s*\|)+\s*:?-*:?\s*\|?$');
final _quote = RegExp(r'^(?:>\s?)+');
@@ -383,25 +393,30 @@ class _PreviewBuilder {
continue;
}
final uri = Uri.tryParse(url);
final deepLink = uri?.scheme == 'buzz' ? parseBuzzDeepLink(uri!) : null;
final deepLink = uri?.scheme == 'buzz'
? parseBuzzDeepLink(uri!) ?? parseEntityDeepLink(uri)
: null;
if (deepLink is ChannelDeepLink) {
add(PreviewChannel(_channelName(deepLink.channelId)));
add(PreviewChannel(buzzChannelLabel(deepLink.channelId, _channelName)));
continue;
}
_tokens(
_unwrapEmphasis(
_shield(label.isEmpty ? url : decodeMarkdownLabelSyntax(label)),
),
);
final shown = label.isEmpty ? url : decodeMarkdownLabelSyntax(label);
// As in the thread view, a link showing its own URL shows a short label.
final short = shown == url ? buzzLinkLabel(deepLink, _channelName) : null;
if (short != null) {
this.text(short);
continue;
}
_tokens(_unwrapEmphasis(_shield(shown)));
}
_tokens(_unwrapEmphasis(source.substring(start)));
}
String _channelName(String channelId) {
String? _channelName(String channelId) {
for (final entry in channelNames.entries) {
if (entry.value == channelId) return entry.key;
}
return channelId.substring(0, channelId.length < 8 ? channelId.length : 8);
return null;
}
/// [_shield]ed plain text with mention, custom emoji and channel tokens.

View File

@@ -109,8 +109,12 @@ void main() {
);
});
test('caps a very long line', () {
test('caps a very long line, and reads only its start', () {
expect(_text('x' * 5000).length, 2000);
expect(
messagePreviewSource('x' * 128000, maxLines: 2).length,
lessThanOrEqualTo(4000),
);
});
});
@@ -221,6 +225,18 @@ void main() {
);
});
test('message and repo links that show their URL get a short label', () {
final message = 'buzz://message?channel=$_channelId&id=${'ab' * 32}';
const repo = 'buzz://repo?owner=$_alice&d=buzz';
expect(
_pieces(
'[$message]($message) [$repo]($repo) [named]($message)',
channelNames: {'general': _channelId},
),
[const PreviewText('general · abababab buzz named')],
);
});
test('media links become their kind', () {
expect(_pieces('look ![](https://x.y/a.png) [clip](https://x.y/b.mp4)'), [
const PreviewText('look '),