Files
zap-stream-flutter/lib/widgets/nostr_text.dart
2025-05-16 14:28:33 +01:00

191 lines
5.5 KiB
Dart

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<InlineSpan> textToSpans(
String content,
List<List<String>> tags,
String pubkey, {
bool? showEmbeds,
bool? embedMedia,
}) {
List<InlineSpan> 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),
),
);
}),
);
}