import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/syntax_highlight.dart'; import '../../shared/theme/theme.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_only.dart'; import 'channels_provider.dart'; import 'media_viewer_page.dart'; import 'message_content/link_normalizer.dart'; import 'message_media.dart'; part 'message_content/media_carousel.dart'; part 'message_content/token_pill.dart'; part 'message_content/video_preview.dart'; const _messageMediaMaxInlineWidth = 320.0; const _messageMediaMaxImageHeight = 240.0; typedef OpenDownloadedFile = Future Function( String url, Map headers, String filename, ); final openDownloadedFileProvider = Provider((ref) { final client = ref.watch(mediaHttpClientProvider); return (url, headers, filename) async { final response = await client.get(Uri.parse(url), headers: headers); if (response.statusCode < 200 || response.statusCode >= 300) { throw HttpException( 'Attachment download failed (${response.statusCode})', uri: Uri.parse(url), ); } final directory = await getTemporaryDirectory(); final safeName = _safeDownloadedFilename(filename); final file = File( '${directory.path}${Platform.pathSeparator}' '${DateTime.now().microsecondsSinceEpoch}-$safeName', ); await file.writeAsBytes(response.bodyBytes, flush: true); final result = await OpenFilex.open(file.path); if (result.type != ResultType.done) { throw FileSystemException(result.message, file.path); } }; }); String _safeDownloadedFilename(String filename) { final safe = filename .split(RegExp(r'[/\\]')) .last .replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '') .trim(); return safe.isEmpty ? 'attachment' : safe; } /// Renders message content with markdown formatting, @mentions, #channel links, /// and media-aware markdown images/videos. class MessageContent extends HookConsumerWidget { final String content; /// Display names for mentioned pubkeys, extracted from event p-tags. /// Keys are lowercase pubkeys, values are display names. final Map mentionNames; /// Mentioned pubkeys that resolve to agents. Agent chips use the desktop /// robot treatment instead of an `@` prefix. final Set agentMentionPubkeys; /// Known channel names for #channel links. Keys are lowercase channel /// names, values are channel IDs. final Map channelNames; /// Raw event tags, used for `imeta` media metadata lookups. final List> tags; /// Called when a #channel link is tapped. final void Function(String channelId)? onChannelTap; /// Called when an @mention of a known user is tapped, with the /// mentioned user's pubkey. final void Function(String pubkey)? onMentionTap; /// Opens the message's thread from the full-screen image viewer. final VoidCallback? onMediaReply; /// Opens message-specific actions for an image in the full-screen viewer. final MediaViewerMoreAction? onMediaMore; final TextStyle? baseStyle; /// Alignment applied to rendered markdown text. final TextAlign? textAlign; final int? maxLines; /// Render a body that is nothing but emoji at [kEmojiOnlyFontSize], the way /// desktop's `MessageRow` does. Off by default: previews, search hits, and /// notification rows want a message to occupy its usual line height whatever /// it contains. final bool scaleEmojiOnly; /// Allows a multi-image carousel to reclaim leading space reserved by the /// surrounding message layout, while keeping its image count aligned with /// the message body. final double mediaCarouselLeadingOverflow; /// Allows a multi-image carousel to continue through the trailing page /// gutter while keeping its first image and count aligned with the body. final double mediaCarouselTrailingOverflow; const MessageContent({ super.key, required this.content, this.mentionNames = const {}, this.agentMentionPubkeys = const {}, this.channelNames = const {}, this.tags = const [], this.onChannelTap, this.onMentionTap, this.onMediaReply, this.onMediaMore, this.baseStyle, this.textAlign, this.maxLines, this.scaleEmojiOnly = false, this.mediaCarouselLeadingOverflow = 0, this.mediaCarouselTrailingOverflow = 0, }); @override Widget build(BuildContext context, WidgetRef ref) { final baseTextStyle = baseStyle ?? context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); final resolvedMentionNames = mentionNames; final resolvedAgentMentionPubkeys = { ...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()), }; final resolvedChannelNames = channelNames.isNotEmpty ? channelNames : { for (final channel in ref.watch(channelsProvider).asData?.value ?? const []) channel.name.toLowerCase(): channel.id, }; final resolvedChannelTap = onChannelTap ?? (String channelId) { ref .read(pendingDeepLinkProvider.notifier) .open(Uri(scheme: 'buzz', host: 'channel', path: channelId)); }; final channelPresentationKey = [ for (final entry in (resolvedChannelNames.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)))) '${entry.key}\u0000${entry.value}', ].join('\u0001'); final imetaByUrl = parseImetaTags(tags); final trailingGallery = maxLines == null ? _extractTrailingImageGallery(content, imetaByUrl) : null; final markdownContent = trailingGallery?.content ?? content; final customEmoji = _mergeCustomEmoji( customEmojiFromTags(tags), ref.watch(customEmojiListProvider), ); final mentionPresentationKey = [ for (final entry in (resolvedMentionNames.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)))) '${entry.key}\u0000${entry.value}', ...(resolvedAgentMentionPubkeys.toList()..sort()), ].join('\u0001'); // Decided here rather than by the caller: this is where the event's own // emoji tags and the community palette have already been merged, and a // `:shortcode:` only counts as emoji if it resolves against that palette. final emojiOnly = scaleEmojiOnly && isEmojiOnlyMessage( markdownContent, nativeEmoji: ref.watch(nativeEmojiGlyphsProvider), customEmoji: customEmoji, ); final style = emojiOnly ? baseTextStyle?.copyWith( fontSize: kEmojiOnlyFontSize, height: kEmojiOnlyHeight, ) : baseTextStyle; final inlineCustomEmojiSize = emojiOnly ? kEmojiOnlyCustomEmojiSize : kCustomEmojiInlineSize; final linkNormalizedContent = useMemoized( () => normalizeBareLinks(markdownContent), [markdownContent], ); final finalContent = useMemoized(() { // Replace spaces with non-breaking spaces inside known mention names // so the gpt_markdown combined regex can match multi-word names // even when caseSensitive is not preserved. // Skip content inside backticks to avoid altering inline code. final mentionParts = linkNormalizedContent.split('`'); final mentionBuf = StringBuffer(); for (var i = 0; i < mentionParts.length; i++) { if (i.isOdd) { mentionBuf.write('`${mentionParts[i]}`'); } else { var segment = mentionParts[i]; for (final name in resolvedMentionNames.values) { if (name.contains(' ')) { final normalizedName = _markdownMentionName(name); segment = segment.replaceAllMapped( RegExp('@${RegExp.escape(name)}', caseSensitive: false), (m) => '@$normalizedName', ); } } mentionBuf.write(segment); } } var result = mentionBuf.toString(); // Ensure channel links at the very start of content don't get // swallowed by markdown processing. if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) { result = '\u200B$result'; } return result; }, [linkNormalizedContent, resolvedMentionNames]); final markdown = KeyedSubtree( key: ValueKey( '$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey', ), child: GptMarkdown( finalContent, style: style, followLinkColor: false, codeBuilder: (context, name, code, closed) => _MessageCodeBlock(name: name, code: code), linkBuilder: (context, linkText, url, linkStyle) => _buildLink( context, ref, linkText, url, linkStyle, style, resolvedChannelTap, resolvedChannelNames, ), imageBuilder: (context, imageUrl) => _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), textAlign: textAlign, maxLines: maxLines, inlineComponents: [ _MentionMd( mentionNames: resolvedMentionNames, agentMentionPubkeys: resolvedAgentMentionPubkeys, onMentionTap: onMentionTap, ), CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), _ChannelLinkMd( channelNames: resolvedChannelNames, onChannelTap: resolvedChannelTap, ), ...MarkdownComponent.inlineComponents, ], ), ); if (trailingGallery == null) return markdown; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (trailingGallery.content.trim().isNotEmpty) markdown, _MessageImageCarousel( key: ValueKey( trailingGallery.items.map((item) => item.url).join('\u0000'), ), items: trailingGallery.items, leadingOverflow: mediaCarouselLeadingOverflow, trailingOverflow: mediaCarouselTrailingOverflow, onReply: onMediaReply, onMore: onMediaMore, ), ], ); } Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) { final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta); if (mediaKind == MessageMediaKind.video) { return _MessageVideoPreview( url: imageUrl, imeta: imeta, onReply: onMediaReply, ); } return _MessageImagePreview( url: imageUrl, imeta: imeta, semanticLabel: imeta?.alt ?? 'Message image', onReply: onMediaReply, onMore: onMediaMore, ); } Widget _buildLink( BuildContext context, WidgetRef ref, InlineSpan linkText, String url, TextStyle linkStyle, TextStyle? fallbackStyle, void Function(String channelId) resolvedChannelTap, Map resolvedChannelNames, ) { String text = ''; linkText.visitChildren((span) { if (span is TextSpan && span.text != null) { text += span.text!; } return true; }); final baseStyle = fallbackStyle ?? linkStyle; final uri = Uri.tryParse(url); final buzzLink = uri?.scheme == 'buzz' ? parseBuzzDeepLink(uri!) ?? parseEntityDeepLink(uri) : null; final isBuzzLink = buzzLink is ChannelDeepLink || buzzLink is MessageDeepLink || buzzLink is EntityDeepLink; final isCanonicalBuzzLabel = isBuzzLink && text == url; final buzzPresentation = switch (buzzLink) { ChannelDeepLink(:final channelId) => ( icon: LucideIcons.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))}', interactive: true, ), MessageDeepLink(:final channelId, :final messageId) => ( icon: LucideIcons.messageSquare, label: '${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))} · ${messageId.substring(0, math.min(8, messageId.length))}', semanticLabel: 'Open message ${messageId.substring(0, math.min(8, messageId.length))} in channel ${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))}', interactive: true, ), EntityDeepLink(:final type, :final repository, :final eventId) => ( icon: switch (type) { 'repo' => LucideIcons.folderGit2, 'pr' => LucideIcons.gitPullRequest, _ => LucideIcons.circleDot, }, label: type == 'repo' ? repository : '$repository · ${eventId!.substring(0, 8)}', semanticLabel: switch (type) { 'repo' => 'Repository $repository', 'pr' => 'Pull request ${eventId!.substring(0, 8)} in repository $repository', _ => 'Issue ${eventId!.substring(0, 8)} in repository $repository', }, interactive: false, ), _ => null, }; final authoredLinkStyle = baseStyle.copyWith( color: context.colors.primary, decoration: TextDecoration.underline, decorationColor: context.colors.primary, ); final linkTextWidget = isCanonicalBuzzLabel ? Text( text, style: baseStyle.copyWith( color: context.colors.primary, fontWeight: FontWeight.w600, ), ) : Text.rich(TextSpan(style: authoredLinkStyle, children: [linkText])); final renderedLink = isCanonicalBuzzLabel && buzzPresentation != null ? _TokenPill( key: ValueKey('buzz-link-chip:$url'), icon: buzzPresentation.icon, interactive: buzzPresentation.interactive, semanticLabel: buzzPresentation.semanticLabel, text: buzzPresentation.label, textStyle: baseStyle.copyWith(fontWeight: FontWeight.w600), ) : linkTextWidget; // Mobile has no repo/PR/issue destination yet. Keep these presentation-only // instead of exposing a control whose tap cannot do anything. if (buzzLink is EntityDeepLink) { return IgnorePointer(child: renderedLink); } return GestureDetector( onTap: () async { final uri = Uri.tryParse(url); if (uri == null) return; // Rendered channel URLs must use the same callback as `#channel` // references so detail-page callers can suppress self-navigation. // Message and join links still need the top-level authenticated // dispatcher. if (uri.scheme == 'buzz') { final deepLink = parseBuzzDeepLink(uri); if (deepLink case ChannelDeepLink(:final channelId)) { resolvedChannelTap(channelId); } else if (deepLink is MessageDeepLink || deepLink is InviteDeepLink) { ref.read(pendingDeepLinkProvider.notifier).open(uri); } return; } if (uri.scheme != 'http' && uri.scheme != 'https') return; final auth = ref.read(mediaGetAuthServiceProvider); if (!auth.isRelayMediaUrl(url)) { await launchUrl(uri, mode: LaunchMode.externalApplication); return; } try { await ref.read(openDownloadedFileProvider)( url, auth.headersFor(url), text, ); } catch (_) { if (!context.mounted) return; ScaffoldMessenger.maybeOf(context)?.showSnackBar( const SnackBar(content: Text('Could not open attachment')), ); } }, child: renderedLink, ); } } List _mergeCustomEmoji( List eventEmoji, List paletteEmoji, ) { if (eventEmoji.isEmpty) return paletteEmoji; if (paletteEmoji.isEmpty) return eventEmoji; final seen = {}; final merged = []; for (final emoji in [...eventEmoji, ...paletteEmoji]) { if (seen.add(emoji.shortcode)) merged.add(emoji); } return merged; } class _MessageImagePreview extends HookConsumerWidget { final String url; final ImetaEntry? imeta; final String semanticLabel; final VoidCallback? onReply; final MediaViewerMoreAction? onMore; const _MessageImagePreview({ required this.url, required this.imeta, required this.semanticLabel, required this.onReply, required this.onMore, }); @override Widget build(BuildContext context, WidgetRef ref) { final heroTag = useMemoized(() => Object()); final layout = _resolveImagePreviewLayout(context, imeta?.aspectRatio); final previewDecodeWidth = layout.width ?? _messageMediaMaxWidth(context); return Padding( padding: const EdgeInsets.only(top: Grid.half), child: GestureDetector( onTap: () => openImageViewer( context, imageUrl: url, heroTag: heroTag, semanticLabel: semanticLabel, previewDecodeWidth: previewDecodeWidth, aspectRatio: imeta?.aspectRatio, onReply: onReply, onMore: onMore, ), child: _MessageMediaPreviewFrame( previewKey: ValueKey('message-media-image-preview:$url'), backgroundColor: context.colors.surfaceContainerHighest, width: layout.width, height: layout.height, constraints: layout.constraints, child: MediaViewerHero( tag: heroTag, child: ClipRRect( borderRadius: BorderRadius.circular(Radii.md), child: MediaImage( url: url, decodeWidth: previewDecodeWidth, fit: layout.fit, semanticLabel: semanticLabel, errorBuilder: (_, _, _) => _MediaPreviewFallback( icon: LucideIcons.imageOff, label: 'Image unavailable', ), ), ), ), ), ), ); } } class _MessageMediaPreviewFrame extends StatelessWidget { final Key previewKey; final Color backgroundColor; final double? width; final double? height; final BoxConstraints? constraints; final Widget child; const _MessageMediaPreviewFrame({ required this.previewKey, required this.backgroundColor, this.width, this.height, this.constraints, required this.child, }); @override Widget build(BuildContext context) { final resolvedWidth = constraints == null ? (width ?? _messageMediaMaxWidth(context)) : width; return Container( key: previewKey, width: resolvedWidth, height: height, constraints: constraints, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(Radii.md), border: Border.all(color: context.colors.outlineVariant), ), child: child, ); } } double _messageMediaMaxWidth(BuildContext context) { return math .min(MediaQuery.sizeOf(context).width * 0.72, _messageMediaMaxInlineWidth) .toDouble(); } _ImagePreviewLayout _resolveImagePreviewLayout( BuildContext context, double? aspectRatio, ) { if (aspectRatio == null) { return _ImagePreviewLayout( constraints: BoxConstraints( maxWidth: _messageMediaMaxWidth(context), maxHeight: _messageMediaMaxImageHeight, ), fit: BoxFit.contain, ); } final previewSize = _imagePreviewSize(context, aspectRatio); return _ImagePreviewLayout( width: previewSize.width, height: previewSize.height, fit: BoxFit.cover, ); } Size _imagePreviewSize(BuildContext context, double? aspectRatio) { final maxWidth = _messageMediaMaxWidth(context); final safeAspectRatio = (aspectRatio ?? 1.0).clamp(0.2, 4.0).toDouble(); var width = maxWidth; var height = width / safeAspectRatio; if (height > _messageMediaMaxImageHeight) { height = _messageMediaMaxImageHeight; width = height * safeAspectRatio; } return Size(width, height); } class _ImagePreviewLayout { final double? width; final double? height; final BoxConstraints? constraints; final BoxFit fit; const _ImagePreviewLayout({ this.width, this.height, this.constraints, required this.fit, }); } class _MediaPreviewFallback extends StatelessWidget { final IconData icon; final String label; const _MediaPreviewFallback({required this.icon, required this.label}); @override Widget build(BuildContext context) { return ColoredBox( color: context.colors.surfaceContainerHighest, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: context.colors.onSurfaceVariant), const SizedBox(height: Grid.quarter), Text( label, style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant, ), textAlign: TextAlign.center, ), ], ), ), ); } } class _MessageCodeBlock extends HookWidget { final String name; final String code; const _MessageCodeBlock({required this.name, required this.code}); @override Widget build(BuildContext context) { final isCopied = useState(false); Future handleCopy() async { await copyToClipboard(context, code, message: 'Copied code to clipboard'); if (!context.mounted) return; isCopied.value = true; Future.delayed(const Duration(seconds: 2), () { if (context.mounted) isCopied.value = false; }); } final codeBaseStyle = TextStyle( fontFamily: 'GeistMono', fontSize: 13, height: 1.5, color: context.colors.onSurface, ); final isDark = context.theme.brightness == Brightness.dark; final codeTheme = isDark ? highlightDarkTheme : highlightLightTheme; final codeSpans = useMemoized( () => highlightCode(code, name, codeTheme, codeBaseStyle), [code, name, isDark], ); return Container( margin: const EdgeInsets.only(top: Grid.half), decoration: BoxDecoration( color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(12), border: Border.all( color: context.colors.outline.withValues(alpha: 0.7), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (name.isNotEmpty) Padding( padding: const EdgeInsets.only( left: Grid.twelve, top: Grid.half + Grid.quarter, ), child: Text( name, style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant, ), ), ), Stack( children: [ Padding( padding: EdgeInsets.fromLTRB( Grid.twelve, name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter, 44, Grid.half + Grid.quarter, ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: RichText( softWrap: false, text: TextSpan(style: codeBaseStyle, children: codeSpans), ), ), ), Positioned( top: 0, right: Grid.quarter, child: SizedBox( width: 28, height: 28, child: IconButton( onPressed: handleCopy, padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, style: IconButton.styleFrom( tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), icon: Icon( isCopied.value ? LucideIcons.check : LucideIcons.copy, size: 14, color: isCopied.value ? context.colors.primary : context.colors.onSurfaceVariant, ), ), ), ), ], ), ], ), ); } } class _MentionMd extends InlineMd { final Map mentionNames; final Set agentMentionPubkeys; final void Function(String pubkey)? onMentionTap; late final RegExp _exp = _buildPrefixPattern( prefix: '@', knownNames: _mentionAliases(mentionNames.values), genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*', ); _MentionMd({ required this.mentionNames, required this.agentMentionPubkeys, this.onMentionTap, }); @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 name = raw.substring(1).replaceAll('\u00A0', ' ').toLowerCase(); String? displayName; String? pubkey; for (final entry in mentionNames.entries) { final entryName = entry.value.toLowerCase(); final firstName = entryName.split(RegExp(r'\s+')).first; if (entryName == name || firstName == name) { displayName = entry.value; pubkey = entry.key; break; } } final isAgent = pubkey != null && agentMentionPubkeys.contains(pubkey.toLowerCase()); final pill = _MentionPill( label: displayName ?? raw.substring(1), isAgent: isAgent, textStyle: config.style, ); return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: pubkey != null && onMentionTap != null ? GestureDetector(onTap: () => onMentionTap!(pubkey!), child: pill) : pill, ); } } class _MentionPill extends StatelessWidget { final String label; final bool isAgent; final TextStyle? textStyle; const _MentionPill({ required this.label, required this.isAgent, this.textStyle, }); @override Widget build(BuildContext context) { final style = (textStyle ?? context.textTheme.bodyMedium)?.copyWith( color: context.colors.primary, fontWeight: FontWeight.w500, height: 1, ) ?? TextStyle( color: context.colors.primary, fontWeight: FontWeight.w500, height: 1, ); final fontSize = style.fontSize ?? 16; return Container( padding: const EdgeInsets.fromLTRB( Grid.half, Grid.quarter + 1, Grid.half, Grid.quarter, ), decoration: BoxDecoration( color: context.colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(Radii.sm), ), child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ if (isAgent) ...[ Icon( LucideIcons.bot, size: fontSize * 0.95, color: context.colors.primary, ), const SizedBox(width: Grid.quarter + 1), ] else Transform.translate( offset: const Offset(0, -Grid.quarter), child: Text('@', style: style), ), Text(label, style: style), ], ), ); } } String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0'); Iterable _mentionAliases(Iterable mentionNames) sync* { for (final name in mentionNames) { final trimmed = name.trim(); if (trimmed.isEmpty) continue; yield _markdownMentionName(trimmed); final firstName = trimmed.split(RegExp(r'\s+')).first; if (firstName.isNotEmpty) { yield firstName; } } }