feat: chat embeds

closes #18
This commit is contained in:
2025-05-16 14:25:16 +01:00
parent b630b59e53
commit c79ea1b872
7 changed files with 351 additions and 79 deletions

View File

@ -38,7 +38,7 @@ class _ChatModalWidget extends State<ChatModalWidget> {
width: double.maxFinite,
decoration: BoxDecoration(color: LAYER_2, borderRadius: DEFAULT_BR),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: NoteText(event: widget.event),
child: NoteText(event: widget.event, showEmbeds: false),
),
Row(
spacing: 8,

View File

@ -1,24 +1,45 @@
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});
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),
children: textToSpans(
event.content,
event.tags,
event.pubKey,
showEmbeds: showEmbeds,
embedMedia: embedMedia,
),
),
);
}
@ -26,21 +47,18 @@ class NoteText extends StatelessWidget {
/// 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,
) {
return _buildContentSpans(content.trim(), tags);
}
/// 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<InlineSpan> _buildContentSpans(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)[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,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*))', // URLs
@ -53,11 +71,11 @@ List<InlineSpan> _buildContentSpans(String content, List<List<String>> tags) {
String? matched = match.group(0);
if (matched != null) {
if (matched.startsWith('nostr:')) {
spans.add(_buildProfileOrNoteSpan(matched));
spans.add(_buildProfileOrNoteSpan(matched, showEmbeds ?? true));
} else if (matched.startsWith('#')) {
spans.add(_buildHashtagSpan(matched));
} else if (matched.startsWith('http')) {
spans.add(_buildUrlSpan(matched));
spans.add(_buildUrlSpan(matched, embedMedia ?? false));
}
}
return '';
@ -87,12 +105,14 @@ List<InlineSpan> _buildContentSpans(String content, List<List<String>> tags) {
return spans;
}
InlineSpan _buildProfileOrNoteSpan(String word) {
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('note') ||
cleanedWord.startsWith('nevent') ||
cleanedWord.startsWith("naddr");
if (isProfile) {
final hexKey = bech32ToHex(cleanedWord);
@ -102,11 +122,13 @@ InlineSpan _buildProfileOrNoteSpan(String word) {
return TextSpan(text: "@$cleanedWord");
}
}
if (isNote) {
final eventId = bech32ToHex(cleanedWord);
return TextSpan(text: eventId, style: TextStyle(color: PRIMARY_1));
if (isNote && showEmbeds) {
return WidgetSpan(
child: NoteEmbedWidget(link: cleanedWord),
alignment: PlaceholderAlignment.middle,
);
} else {
return TextSpan(text: word);
return TextSpan(text: word, style: TextStyle(color: PRIMARY_1));
}
}
@ -114,7 +136,29 @@ InlineSpan _buildHashtagSpan(String word) {
return TextSpan(text: word, style: TextStyle(color: PRIMARY_1));
}
InlineSpan _buildUrlSpan(String url) {
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),

View File

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:ndk/ndk.dart';
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/nostr_text.dart';
import 'package:zap_stream_flutter/widgets/pill.dart';
import 'package:zap_stream_flutter/widgets/profile.dart';
class NoteEmbedWidget extends StatelessWidget {
final String link;
const NoteEmbedWidget({super.key, required this.link});
@override
Widget build(BuildContext context) {
final entity = decodeBech32ToTLVEntity(link);
return RxFilter<Nip01Event>(
Key("embeded-note:$link"),
filters: [entity.toFilter()],
builder: (context, data) {
final note = data != null && data.isNotEmpty ? data.first : null;
return PillWidget(
onTap: () {
if (note != null) {
// redirect to the stream if its a live stream link
if (note.kind == 30_311) {
context.push("/e/$link", extra: StreamEvent(note));
return;
}
showModalBottomSheet(
context: context,
builder: (context) {
return SingleChildScrollView(child: _NotePreview(note: note));
},
);
}
},
color: LAYER_3,
child: RichText(
text: TextSpan(
children: [
WidgetSpan(child: Icon(Icons.link, size: 16)),
TextSpan(
text: switch (entity.kind) {
30_023 => " Article by ",
30_311 => " Live Stream by ",
_ => " Note by ",
},
),
if (note?.pubKey != null)
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: ProfileNameWidget.pubkey(switch (note!.kind) {
30_311 => StreamEvent(note).info.host,
_ => note.pubKey,
}, linkToProfile: false),
),
],
),
),
);
},
);
}
}
class _NotePreview extends StatelessWidget {
final Nip01Event note;
const _NotePreview({required this.note});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(10),
decoration: BoxDecoration(color: LAYER_1, borderRadius: DEFAULT_BR),
child: Column(
spacing: 8,
children: [
ProfileWidget.pubkey(note.pubKey),
NoteText(event: note, embedMedia: true),
],
),
);
}
}

View File

@ -24,15 +24,27 @@ class ProfileLoaderWidget extends StatelessWidget {
class ProfileNameWidget extends StatelessWidget {
final Metadata profile;
final TextStyle? style;
final bool? linkToProfile;
const ProfileNameWidget({super.key, required this.profile, this.style});
const ProfileNameWidget({
super.key,
required this.profile,
this.style,
this.linkToProfile,
});
static Widget pubkey(String pubkey, {Key? key, TextStyle? style}) {
static Widget pubkey(
String pubkey, {
Key? key,
TextStyle? style,
bool? linkToProfile,
}) {
return ProfileLoaderWidget(
pubkey,
(ctx, data) => ProfileNameWidget(
profile: data.data ?? Metadata(pubKey: pubkey),
style: style,
linkToProfile: linkToProfile,
),
key: key,
);
@ -50,14 +62,22 @@ class ProfileNameWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap:
() => context.push(
"/p/${Nip19.encodePubKey(profile.pubKey)}",
extra: profile,
),
child: Text(ProfileNameWidget.nameFromProfile(profile), style: style),
final inner = Text(
ProfileNameWidget.nameFromProfile(profile),
style: style,
);
if (linkToProfile ?? true) {
return GestureDetector(
onTap:
() => context.push(
"/p/${Nip19.encodePubKey(profile.pubKey)}",
extra: profile,
),
child: inner,
);
} else {
return inner;
}
}
}

View File

@ -0,0 +1,57 @@
import 'package:chewie/chewie.dart';
import 'package:flutter/widgets.dart';
import 'package:video_player/video_player.dart';
import 'package:zap_stream_flutter/imgproxy.dart';
import 'package:zap_stream_flutter/main.dart';
class VideoPlayerWidget extends StatefulWidget {
final String url;
final String? placeholder;
final double? aspectRatio;
final bool? autoPlay;
const VideoPlayerWidget({
super.key,
required this.url,
this.placeholder,
this.aspectRatio,
this.autoPlay,
});
@override
State<StatefulWidget> createState() => _VideoPlayerWidget();
}
class _VideoPlayerWidget extends State<VideoPlayerWidget> {
late final ChewieController _chewieController;
@override
void initState() {
super.initState();
final controller = VideoPlayerController.networkUrl(
Uri.parse(widget.url),
httpHeaders: Map.from({"user-agent": userAgent}),
);
_chewieController = ChewieController(
videoPlayerController: controller,
autoPlay: widget.autoPlay ?? true,
autoInitialize: true,
placeholder:
(widget.placeholder?.isNotEmpty ?? false)
? ProxyImg(url: widget.placeholder!)
: null,
);
}
@override
void dispose() {
super.dispose();
_chewieController.dispose();
}
@override
Widget build(BuildContext context) {
return Chewie(controller: _chewieController);
}
}