import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:go_router/go_router.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip19/nip19.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zap_stream_flutter/imgproxy.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; import 'package:zap_stream_flutter/widgets/custom_emoji.dart'; import 'package:zap_stream_flutter/widgets/note_embed.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; import 'package:zap_stream_flutter/widgets/video_player.dart'; class NoteText extends StatelessWidget { final Nip01Event event; final bool? embedMedia; final bool? showEmbeds; const NoteText({ super.key, required this.event, this.embedMedia, this.showEmbeds, }); @override Widget build(BuildContext context) { // use markdown rendering for articles if (event.kind == 30_023) { return MarkdownBody(data: event.content); } return RichText( text: TextSpan( children: textToSpans( event.content, event.tags, event.pubKey, showEmbeds: showEmbeds, embedMedia: embedMedia, ), ), ); } } /// Converts a nostr note text containing links /// and mentions into multiple spans for rendering /// /// https://github.com/leo-lox/camelus/blob/f58455a0ac07fcc780bdc69b8f4544fd5ea4a46d/lib/presentation_layer/components/note_card/note_card_build_split_content.dart#L262 List textToSpans( String content, List> tags, String pubkey, { bool? showEmbeds, bool? embedMedia, }) { List spans = []; RegExp exp = RegExp( r'nostr:(nprofile|npub)[a-zA-Z0-9]+|' r'nostr:(note|nevent|naddr)[a-zA-Z0-9]+|' r'(#\$\$\s*[0-9]+\s*\$\$)|' r'(#\w+)|' // Hashtags r'(https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,10}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*))', // URLs caseSensitive: false, ); content.splitMapJoin( exp, onMatch: (Match match) { String? matched = match.group(0); if (matched != null) { if (matched.startsWith('nostr:')) { spans.add(_buildProfileOrNoteSpan(matched, showEmbeds ?? true)); } else if (matched.startsWith('#')) { spans.add(_buildHashtagSpan(matched)); } else if (matched.startsWith('http')) { spans.add(_buildUrlSpan(matched, embedMedia ?? false)); } } return ''; }, onNonMatch: (String text) { final textTrim = text.trim(); if (textTrim.startsWith(":") && textTrim.endsWith(":") && tags.any( (t) => t[0] == "emoji" && t[1] == textTrim.substring(1, textTrim.length - 1), )) { spans.add( WidgetSpan( alignment: PlaceholderAlignment.middle, child: CustomEmoji(emoji: textTrim, tags: tags, size: 24), ), ); } else { spans.add(TextSpan(text: text)); } return ''; }, ); return spans; } InlineSpan _buildProfileOrNoteSpan(String word, bool showEmbeds) { final cleanedWord = word.replaceAll('nostr:', ''); final isProfile = cleanedWord.startsWith('nprofile') || cleanedWord.startsWith('npub'); final isNote = cleanedWord.startsWith('note') || cleanedWord.startsWith('nevent') || cleanedWord.startsWith("naddr"); if (isProfile) { final hexKey = bech32ToHex(cleanedWord); if (hexKey.isNotEmpty) { return _inlineMention(hexKey); } else { return TextSpan(text: "@$cleanedWord"); } } if (isNote && showEmbeds) { return WidgetSpan( child: NoteEmbedWidget(link: cleanedWord), alignment: PlaceholderAlignment.middle, ); } else { return TextSpan(text: word, style: TextStyle(color: PRIMARY_1)); } } InlineSpan _buildHashtagSpan(String word) { return TextSpan(text: word, style: TextStyle(color: PRIMARY_1)); } InlineSpan _buildUrlSpan(String url, bool embedMedia) { if (embedMedia && (url.endsWith(".jpg") || url.endsWith(".gif") || url.endsWith(".jpeg") || url.endsWith(".webp") || url.endsWith(".png") || url.endsWith(".bmp"))) { return WidgetSpan(child: ProxyImg(url: url)); } if (embedMedia && (url.endsWith(".mp4") || url.endsWith(".mov") || url.endsWith(".webm") || url.endsWith(".mkv") || url.endsWith(".m3u8"))) { return WidgetSpan( child: AspectRatio( aspectRatio: 16 / 9, child: Center(child: VideoPlayerWidget(url: url, autoPlay: false)), ), ); } return TextSpan( text: url, style: TextStyle(color: PRIMARY_1), recognizer: TapGestureRecognizer() ..onTap = () { launchUrl(Uri.parse(url)); }, ); } InlineSpan _inlineMention(String pubkey) { return WidgetSpan( alignment: PlaceholderAlignment.middle, child: ProfileLoaderWidget(pubkey, (ctx, profile) { return GestureDetector( onTap: () => ctx.push( "/p/${Nip19.encodePubKey(pubkey)}", extra: profile.data, ), child: Text( "@${ProfileNameWidget.nameFromProfile(profile.data ?? Metadata(pubKey: pubkey))}", style: TextStyle(color: PRIMARY_1), ), ); }), ); }