mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
**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.  --------- 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>
139 lines
3.9 KiB
Dart
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,
|
|
);
|
|
}
|