Files
buzz/mobile/lib/features/channels/message_content/token_pill.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

139 lines
3.9 KiB
Dart

part of '../message_content.dart';
String? _channelNameForId(Map<String, String> channels, String channelId) {
for (final entry in channels.entries) {
if (entry.value == channelId) return entry.key;
}
return null;
}
class _ChannelLinkMd extends InlineMd {
final Map<String, String> channelNames;
final void Function(String channelId)? onChannelTap;
late final RegExp _exp = _buildPrefixPattern(
prefix: '#',
knownNames: channelNames.keys,
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*',
);
_ChannelLinkMd({required this.channelNames, this.onChannelTap});
@override
RegExp get exp => _exp;
@override
InlineSpan span(
BuildContext context,
String text,
final GptMarkdownConfig config,
) {
final raw = exp.firstMatch(text.trim())?.group(0);
if (raw == null) {
return TextSpan(text: text, style: config.style);
}
final channelId = channelNames[raw.substring(1).toLowerCase()];
final channelName = raw.substring(1);
final opensChannel = channelId != null && onChannelTap != null;
final child = _TokenPill(
icon: LucideIcons.hash,
interactive: opensChannel,
semanticLabel: opensChannel
? 'Open channel $channelName'
: 'Channel $channelName',
text: channelName,
textStyle: config.style?.copyWith(fontWeight: FontWeight.w500),
);
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: opensChannel
? GestureDetector(onTap: () => onChannelTap!(channelId), child: child)
: child,
);
}
}
class _TokenPill extends StatelessWidget {
final IconData? icon;
final bool interactive;
final String? semanticLabel;
final String text;
final TextStyle? textStyle;
const _TokenPill({
super.key,
this.icon,
this.interactive = false,
this.semanticLabel,
required this.text,
this.textStyle,
});
@override
Widget build(BuildContext context) {
final style =
textStyle?.copyWith(color: context.colors.primary) ??
context.textTheme.bodyMedium?.copyWith(color: context.colors.primary);
final fontSize = style?.fontSize ?? 16;
final pill = Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: fontSize * 0.95, color: context.colors.primary),
const SizedBox(width: Grid.quarter + 1),
],
Text(text, style: style),
],
),
);
if (semanticLabel == null) return pill;
return Semantics(
label: semanticLabel,
button: interactive,
child: ExcludeSemantics(child: pill),
);
}
}
RegExp _buildPrefixPattern({
required String prefix,
required Iterable<String> knownNames,
required String genericTokenPattern,
}) {
final names =
knownNames
.map((name) => name.trim())
.where((name) => name.isNotEmpty)
.toSet()
.toList()
..sort((a, b) => b.length.compareTo(a.length));
final escapedPrefix = RegExp.escape(prefix);
const leadingBoundary = r'(?<![\w./:-])';
const trailingBoundary = r'(?=$|[\s,;.!?:)\]}])';
if (names.isEmpty) {
return RegExp(
'$leadingBoundary$escapedPrefix(?:$genericTokenPattern)$trailingBoundary',
caseSensitive: false,
multiLine: true,
);
}
final knownAlternatives = names.map(RegExp.escape).join('|');
return RegExp(
'$leadingBoundary$escapedPrefix(?:(?:$knownAlternatives)$trailingBoundary|(?:$genericTokenPattern)$trailingBoundary)',
caseSensitive: false,
multiLine: true,
);
}