Files
buzz/mobile/lib/features/channels/message_content/link_normalizer.dart
Taylor Ho 5ddf23d700 feat(mobile-messages): render compact Buzz permalink chips (#5639)
**Category:** improvement
**User Impact:** Buzz channel, message, repository, pull request, and
issue links now display recognizable context and navigate reliably in
the mobile app.
**Problem:** Bare Buzz permalinks appeared as raw or ambiguous URLs on
mobile, while channel and message links were not handled consistently
across Markdown forms and startup states.
**Solution:** Normalize eligible bare Buzz URLs without consuming
Markdown syntax, render them as semantic icon-prefixed chips, and route
channel/message targets through the mobile deep-link dispatcher while
preserving authored Markdown labels as ordinary links.

<details>
<summary>File changes</summary>

**mobile/lib/features/channels/deep_link_dispatcher.dart**
Routes parsed channel and message links through the appropriate in-app
navigation callbacks.

**mobile/lib/features/channels/message_content.dart**
Presents all bare Buzz permalinks as semantic icon chips and keeps
authored labels as ordinary links.

**mobile/lib/features/channels/message_content/link_normalizer.dart**
Normalizes bare and autolinked Buzz URLs without consuming Markdown
delimiters, code, or punctuation.

**mobile/lib/shared/deeplink/deep_link.dart**
Adds strict channel and project-entity parsing alongside message deep
links.

**mobile/lib/shared/deeplink/pending_deep_link_provider.dart**
Preserves pending navigation until the mobile routing surface is ready.

**mobile/test/features/channels/channel_detail_page_test.dart**
Updates navigation integration coverage for icon-prefixed channel chips.

**mobile/test/features/channels/deep_link_dispatcher_test.dart**
Covers channel/message dispatch and missing-target behavior.


**mobile/test/features/channels/message_content/link_normalizer_test.dart**
Exercises Markdown-safe normalization across the full Buzz link suite.

**mobile/test/features/channels/message_content_test.dart**
Verifies chip labels, icons, semantics, authored-label opt-out, and
navigation callbacks.

**mobile/test/shared/deeplink/deep_link_test.dart**
Covers strict parsing for channel, message, repository, pull-request,
and issue links.

</details>

## Reproduction steps
1. Run the mobile app and open a channel containing bare
`buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and
`buzz://issue` URLs.
2. Confirm each bare URL renders as one cohesive chip with a type icon,
a useful name or shortened identifier, and no duplicated channel `#`
character.
3. Add an authored Markdown link such as `[design
discussion](buzz://issue?...)` and confirm the supplied label remains an
ordinary link rather than becoming a chip.
4. Select channel and message links and confirm they navigate correctly
from inline and autolinked forms.

## Screenshots / demos
**iOS Simulator — channel, message, repository, pull request, and issue
permalink chips**

Real app build (`37b2cb5eb`) running on an iPhone 17 Pro simulator.

![Mobile permalink chips on iOS
Simulator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5639/mobile-permalink-chips-simulator.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-14 10:44:58 -07:00

189 lines
6.1 KiB
Dart

const _markdownDelimiters = ['***', '___', '**', '__', '~~', '*', '_'];
final _autolinkPattern = RegExp(
r'<((?:https?://|buzz://(?:message\?|join\?|channel/|(?:pr|issue|repo)\?))[^>]+)>',
);
final _bareLinkPattern = RegExp(
r'(?<![(\]=])(?:https?://|buzz://(?:message\?|join\?|channel/|(?:pr|issue|repo)\?))[^\s)>\]]+',
);
final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$');
final _backtickRunPattern = RegExp(r'`+');
/// Converts supported Buzz and HTTP(S) autolinks and bare links into Markdown
/// links while leaving inline and fenced code untouched. Punctuation peeling
/// is limited to Buzz URLs so existing HTTP(S) destinations stay unchanged.
String normalizeBareLinks(String content) {
final buffer = StringBuffer();
var offset = 0;
var proseStart = 0;
var codeStart = 0;
var inlineDelimiterLength = 0;
var fenceDelimiterLength = 0;
while (offset < content.length) {
final run = _backtickRunPattern.matchAsPrefix(content, offset);
if (run == null) {
offset++;
continue;
}
final runLength = run.end - run.start;
if (fenceDelimiterLength > 0) {
if (_isClosingFence(content, run.start, run.end, fenceDelimiterLength)) {
buffer.write(content.substring(codeStart, run.end));
fenceDelimiterLength = 0;
proseStart = run.end;
}
} else if (inlineDelimiterLength > 0) {
if (runLength == inlineDelimiterLength) {
buffer.write(content.substring(codeStart, run.end));
inlineDelimiterLength = 0;
proseStart = run.end;
}
} else if (_hasInlineCloserOnLine(content, run.end, runLength) ||
(!_isOpeningFence(content, run.start, runLength) &&
_hasInlineCloser(content, run.end, runLength))) {
buffer.write(
_normalizeLinkSegment(content.substring(proseStart, run.start)),
);
codeStart = run.start;
inlineDelimiterLength = runLength;
} else if (_isOpeningFence(content, run.start, runLength)) {
buffer.write(
_normalizeLinkSegment(content.substring(proseStart, run.start)),
);
codeStart = run.start;
fenceDelimiterLength = runLength;
}
offset = run.end;
}
if (inlineDelimiterLength > 0 || fenceDelimiterLength > 0) {
buffer.write(content.substring(codeStart));
} else {
buffer.write(_normalizeLinkSegment(content.substring(proseStart)));
}
return buffer.toString();
}
bool _isOpeningFence(String content, int runStart, int runLength) {
if (runLength < 3) return false;
final lineStart = runStart == 0
? 0
: content.lastIndexOf('\n', runStart - 1) + 1;
final indentation = content.substring(lineStart, runStart);
return indentation.length <= 3 && indentation.trim().isEmpty;
}
bool _isClosingFence(
String content,
int runStart,
int runEnd,
int openerLength,
) {
if (runEnd - runStart < openerLength) return false;
final lineStart = runStart == 0
? 0
: content.lastIndexOf('\n', runStart - 1) + 1;
final indentation = content.substring(lineStart, runStart);
if (indentation.length > 3 || indentation.trim().isNotEmpty) return false;
final newline = content.indexOf('\n', runEnd);
final lineEnd = newline < 0 ? content.length : newline;
return content.substring(runEnd, lineEnd).trim().isEmpty;
}
bool _hasInlineCloserOnLine(String content, int start, int delimiterLength) {
final newline = content.indexOf('\n', start);
final lineEnd = newline < 0 ? content.length : newline;
for (final run in _backtickRunPattern.allMatches(content, start)) {
if (run.start >= lineEnd) return false;
if (run.end - run.start == delimiterLength) return true;
}
return false;
}
bool _hasInlineCloser(String content, int start, int delimiterLength) {
for (final run in _backtickRunPattern.allMatches(content, start)) {
if (run.end - run.start == delimiterLength) return true;
}
return false;
}
String _normalizeLinkSegment(String segment) {
var normalized = segment.replaceAllMapped(
_autolinkPattern,
(match) => '[${match[1]}](${match[1]})',
);
normalized = normalized.replaceAllMapped(
_bareLinkPattern,
(match) => _normalizeBareLink(normalized, match),
);
return normalized;
}
String _normalizeBareLink(String segment, Match match) {
final matched = match[0]!;
var url = matched;
var trailing = '';
final isBuzzUrl = matched.startsWith('buzz://');
final start = match.start;
if (isBuzzUrl) {
final outsidePunctuation = _trailingPunctuationPattern.firstMatch(url);
if (outsidePunctuation != null) {
url = url.substring(0, outsidePunctuation.start);
trailing = outsidePunctuation[0]!;
}
}
var strippedDelimiter = true;
while (strippedDelimiter) {
strippedDelimiter = false;
for (final delimiter in _markdownDelimiters) {
if (url.endsWith(delimiter) &&
_hasUnclosedMarkdownDelimiter(
segment.substring(0, start),
delimiter,
)) {
url = url.substring(0, url.length - delimiter.length);
trailing = '$delimiter$trailing';
strippedDelimiter = true;
break;
}
}
}
if (isBuzzUrl) {
final punctuation = _trailingPunctuationPattern.firstMatch(url);
if (punctuation != null) {
url = url.substring(0, punctuation.start);
trailing = '${punctuation[0]}$trailing';
}
}
// Preserve a URL already used as its own Markdown label. This covers both
// converted autolinks and authored `[url](url)` links.
if (start >= 1 && segment[start - 1] == '[') return matched;
return '[$url]($url)$trailing';
}
bool _hasUnclosedMarkdownDelimiter(String prefix, String delimiter) {
var open = false;
var offset = 0;
while (true) {
final index = prefix.indexOf(delimiter, offset);
if (index < 0) return open;
final before = index == 0 ? null : prefix[index - 1];
final afterIndex = index + delimiter.length;
final after = afterIndex == prefix.length ? null : prefix[afterIndex];
final canOpen =
(after == null || after.trim().isNotEmpty) &&
(before == null ||
before.trim().isEmpty ||
RegExp(r'[^\w]').hasMatch(before));
if (open || canOpen) open = !open;
offset = afterIndex;
}
}