From 8c721ba3a99b73a3e5966771c9b41c2a61687c77 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 11:01:52 -0700 Subject: [PATCH] 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 Signed-off-by: Tom Brow --- .../features/channels/message_content.dart | 26 +++++----- .../message_content/buzz_link_label.dart | 28 +++++++++++ .../message_content/preview_text.dart | 47 ++++++++++++------- .../message_content/preview_text_test.dart | 18 ++++++- 4 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 mobile/lib/features/channels/message_content/buzz_link_label.dart diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 4673ccf1cf..7bbcb038ed 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -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, ), diff --git a/mobile/lib/features/channels/message_content/buzz_link_label.dart b/mobile/lib/features/channels/message_content/buzz_link_label.dart new file mode 100644 index 0000000000..39cb16ac2a --- /dev/null +++ b/mobile/lib/features/channels/message_content/buzz_link_label.dart @@ -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)); diff --git a/mobile/lib/features/channels/message_content/preview_text.dart b/mobile/lib/features/channels/message_content/preview_text.dart index dbae88023b..357379025f 100644 --- a/mobile/lib/features/channels/message_content/preview_text.dart +++ b/mobile/lib/features/channels/message_content/preview_text.dart @@ -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. diff --git a/mobile/test/features/channels/message_content/preview_text_test.dart b/mobile/test/features/channels/message_content/preview_text_test.dart index 970e8b44cf..0d194ca547 100644 --- a/mobile/test/features/channels/message_content/preview_text_test.dart +++ b/mobile/test/features/channels/message_content/preview_text_test.dart @@ -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 '),