From 15fb04cf6216ae0004a6b36f485850c8d1ad9818 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 12 May 2025 14:58:07 +0100 Subject: [PATCH] feat: chat parsing closes #9 --- lib/widgets/chat.dart | 3 +- lib/widgets/nostr_text.dart | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/nostr_text.dart diff --git a/lib/widgets/chat.dart b/lib/widgets/chat.dart index a5bf846..acde16d 100644 --- a/lib/widgets/chat.dart +++ b/lib/widgets/chat.dart @@ -8,6 +8,7 @@ import 'package:zap_stream_flutter/rx_filter.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; import 'package:zap_stream_flutter/widgets/avatar.dart'; +import 'package:zap_stream_flutter/widgets/nostr_text.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; import 'package:zap_stream_flutter/widgets/profile_modal.dart'; @@ -291,7 +292,7 @@ class _ChatMessageWidget extends StatelessWidget { ), ), TextSpan(text: " "), - TextSpan(text: msg.content, style: TextStyle(color: FONT_COLOR)), + ...textToSpans(msg.content, msg.tags, msg.pubKey), ], ), ); diff --git a/lib/widgets/nostr_text.dart b/lib/widgets/nostr_text.dart new file mode 100644 index 0000000..a5250c2 --- /dev/null +++ b/lib/widgets/nostr_text.dart @@ -0,0 +1,108 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.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/theme.dart'; +import 'package:zap_stream_flutter/widgets/profile.dart'; + +/// Converts a nostr note text containing links +/// and mentions into multiple spans for rendering +List textToSpans( + String content, + List> tags, + String pubkey, +) { + return _buildContentSpans(content); +} + +/// Content parser from camelus +/// https://github.com/leo-lox/camelus/blob/f58455a0ac07fcc780bdc69b8f4544fd5ea4a46d/lib/presentation_layer/components/note_card/note_card_build_split_content.dart#L262 +List _buildContentSpans(String content) { + List spans = []; + RegExp exp = RegExp( + r'nostr:(nprofile|npub)[a-zA-Z0-9]+|' + r'nostr:(note|nevent)[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,6}\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)); + } else if (matched.startsWith('#')) { + spans.add(_buildHashtagSpan(matched)); + } else if (matched.startsWith('http')) { + spans.add(_buildUrlSpan(matched)); + } + } + return ''; + }, + onNonMatch: (String text) { + spans.add(TextSpan(text: text)); + return ''; + }, + ); + + return spans; +} + +InlineSpan _buildProfileOrNoteSpan(String word) { + final cleanedWord = word.replaceAll('nostr:', ''); + final isProfile = + cleanedWord.startsWith('nprofile') || cleanedWord.startsWith('npub'); + final isNote = + cleanedWord.startsWith('note') || cleanedWord.startsWith('nevent'); + + if (isProfile) { + return _inlineMention(Nip19.decode(cleanedWord)); + } + if (isNote) { + final eventId = Nip19.decode(cleanedWord); + return TextSpan(text: eventId, style: TextStyle(color: PRIMARY_1)); + } else { + return TextSpan(text: word); + } +} + +InlineSpan _buildHashtagSpan(String word) { + return TextSpan(text: word, style: TextStyle(color: PRIMARY_1)); +} + +InlineSpan _buildUrlSpan(String url) { + 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), + ), + ); + }), + ); +}