From e9062f02651e963a4c7d942decf387ab6ea9dfc6 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 27 May 2025 12:22:28 +0100 Subject: [PATCH] feat: custom emoji picker closes #14 --- lib/widgets/chat_write.dart | 38 +++++++-- lib/widgets/emoji.dart | 161 ++++++++++++++++++++++++++++++++++++ lib/widgets/nostr_text.dart | 32 ++++--- lib/widgets/reaction.dart | 43 ++-------- pubspec.lock | 21 ++--- pubspec.yaml | 3 + 6 files changed, 225 insertions(+), 73 deletions(-) create mode 100644 lib/widgets/emoji.dart diff --git a/lib/widgets/chat_write.dart b/lib/widgets/chat_write.dart index a4a8fbc..ed5b625 100644 --- a/lib/widgets/chat_write.dart +++ b/lib/widgets/chat_write.dart @@ -1,5 +1,4 @@ import 'package:collection/collection.dart'; -import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip19/nip19.dart'; @@ -8,8 +7,8 @@ import 'package:zap_stream_flutter/const.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/emoji.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; -import 'package:zap_stream_flutter/widgets/reaction.dart'; class WriteMessageWidget extends StatefulWidget { final StreamEvent stream; @@ -24,6 +23,7 @@ class __WriteMessageWidget extends State { late final TextEditingController _controller; OverlayEntry? _entry; late FocusNode _focusNode; + List> _tags = List.empty(growable: true); @override void initState() { @@ -177,11 +177,24 @@ class __WriteMessageWidget extends State { color: LAYER_2, borderRadius: DEFAULT_BR, ), - child: EmojiPicker( - onEmojiSelected: (category, emoji) { - _controller.text = _controller.text + emoji.emoji; + child: EmojiPickerCustom( + customEmojiSets: [widget.stream.info.host], + onEmojiSelected: (emoji) { + _controller.text = + _controller.text + + (emoji.emoji.startsWith("http") + ? ":${emoji.name}:" + : emoji.emoji); + if (emoji.emoji.startsWith("http")) { + setState(() { + _tags = + [ + ..._tags, + ["emoji", emoji.name, emoji.emoji], + ].toList(); + }); + } }, - config: emojiPickerConfig, ), ), ), @@ -196,13 +209,20 @@ class __WriteMessageWidget extends State { final login = ndk.accounts.getLoggedAccount(); if (login == null || _controller.text.isEmpty) return; + var tags = [ + ["a", widget.stream.aTag], + ..._tags.where( + (t) => switch (t[0]) { + "emoji" => _controller.text.contains(":${t[1]}:"), + _ => true, + }, + ), + ]; final chatMsg = Nip01Event( pubKey: login.pubkey, kind: 1311, content: _controller.text.toString(), - tags: [ - ["a", widget.stream.aTag], - ], + tags: tags, ); _controller.clear(); _focusNode.unfocus(); diff --git a/lib/widgets/emoji.dart b/lib/widgets/emoji.dart new file mode 100644 index 0000000..e33e4d7 --- /dev/null +++ b/lib/widgets/emoji.dart @@ -0,0 +1,161 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emoji; +import 'package:emoji_picker_flutter/locales/default_emoji_set_locale.dart'; +import 'package:flutter/foundation.dart' as foundation; +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/const.dart'; +import 'package:zap_stream_flutter/rx_filter.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/utils.dart'; + +class EmojiPickerCustom extends StatelessWidget { + final List? customEmojiSets; + final void Function(emoji.Emoji)? onEmojiSelected; + + const EmojiPickerCustom({ + super.key, + this.onEmojiSelected, + this.customEmojiSets, + }); + + Widget _picker(List customEmojiTags) { + return emoji.EmojiPicker( + config: emoji.Config( + emojiSet: (locale) { + var ret = List.from( + getDefaultEmojiLocale(locale), + ); + + // append custom emoji + final custom = emoji.Category(name: "Custom", icon: Icons.star); + ret.add( + emoji.CategoryEmoji( + custom, + customEmojiTags + .map((a) => a.emoji) + .flattened + .map( + (e) => emoji.Emoji( + e.url, + e.name, + widget: CachedNetworkImage( + imageUrl: e.url, + height: 24, + width: 24, + ), + ), + ) + .toList(), + ), + ); + + return ret; + }, + height: 256, + checkPlatformCompatibility: false, + emojiViewConfig: emoji.EmojiViewConfig( + emojiSizeMax: + 28 * + (foundation.defaultTargetPlatform == TargetPlatform.iOS + ? 1.20 + : 1.0), + backgroundColor: LAYER_1, + ), + viewOrderConfig: const emoji.ViewOrderConfig( + top: emoji.EmojiPickerItem.categoryBar, + middle: emoji.EmojiPickerItem.emojiView, + bottom: emoji.EmojiPickerItem.searchBar, + ), + bottomActionBarConfig: emoji.BottomActionBarConfig( + backgroundColor: LAYER_2, + buttonColor: PRIMARY_1, + showBackspaceButton: false, + ), + categoryViewConfig: emoji.CategoryViewConfig( + backgroundColor: LAYER_2, + recentTabBehavior: emoji.RecentTabBehavior.NONE, + ), + searchViewConfig: emoji.SearchViewConfig( + backgroundColor: LAYER_2, + buttonIconColor: PRIMARY_1, + ), + ), + onEmojiSelected: (_, e) { + if (onEmojiSelected != null) { + onEmojiSelected!(e); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + var emojiPubkeys = customEmojiSets ?? []; + final signer = ndk.accounts.getLoggedAccount()?.signer; + if (signer == null) { + return SizedBox.fromSize(); + } + emojiPubkeys.add(signer.getPublicKey()); + + return FutureBuilder( + future: + ndk.requests + .query( + filters: [ + Filter(kinds: [Nip51List.kEmojis], authors: emojiPubkeys), + ], + ) + .future, + builder: (context, state) { + final sets = + state.data + ?.map((a) => a.tags.where((b) => b[0] == "a")) + .flattened + .map((e) => e[1]) + .toSet(); + if (sets == null || sets.isEmpty) { + return _picker([]); + } + return RxFilter( + Key("emoji-picker"), + filters: sets.map(aTagToFilter).toList(), + builder: (context, state) { + return _picker( + (state ?? []).map((e) => CustomEmojiSet(event: e)).toList(), + ); + }, + ); + }, + ); + } +} + +class CustomEmojiSet { + final Nip01Event _event; + + CustomEmojiSet({required Nip01Event event}) : _event = event; + + String get title { + return _event.getFirstTag("title") ?? _event.getDtag()!; + } + + List get emoji { + return _event.tags + .where((t) => t[0] == "emoji") + .map(CustomEmoji.fromTag) + .toList(); + } +} + +class CustomEmoji { + final String name; + final String url; + + CustomEmoji({required this.name, required this.url}); + + static CustomEmoji fromTag(List tag) { + return CustomEmoji(name: tag[1], url: tag[2]); + } +} diff --git a/lib/widgets/nostr_text.dart b/lib/widgets/nostr_text.dart index 902a0c5..513251c 100644 --- a/lib/widgets/nostr_text.dart +++ b/lib/widgets/nostr_text.dart @@ -61,6 +61,7 @@ List textToSpans( r'nostr:(note|nevent|naddr)[a-zA-Z0-9]+|' r'(#\$\$\s*[0-9]+\s*\$\$)|' r'(#\w+)|' // Hashtags + r'(:\w+:)|' // custom emoji r'(https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,10}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*))', // URLs caseSensitive: false, ); @@ -76,28 +77,25 @@ List textToSpans( spans.add(_buildHashtagSpan(matched)); } else if (matched.startsWith('http')) { spans.add(_buildUrlSpan(matched, embedMedia ?? false)); + } else if (matched.startsWith(":") && + matched.endsWith(":") && + tags.any( + (t) => + t[0] == "emoji" && + t[1] == matched.substring(1, matched.length - 1), + )) { + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: CustomEmoji(emoji: matched, tags: tags, size: 24), + ), + ); } } 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)); - } + spans.add(TextSpan(text: text)); return ''; }, ); diff --git a/lib/widgets/reaction.dart b/lib/widgets/reaction.dart index fce16aa..5aa7339 100644 --- a/lib/widgets/reaction.dart +++ b/lib/widgets/reaction.dart @@ -1,35 +1,8 @@ -import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; -import 'package:flutter/foundation.dart' as foundation; import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; import 'package:ndk/entities.dart'; import 'package:zap_stream_flutter/const.dart'; -import 'package:zap_stream_flutter/theme.dart'; - -final emojiPickerConfig = Config( - height: 256, - checkPlatformCompatibility: true, - emojiViewConfig: EmojiViewConfig( - emojiSizeMax: - 28 * - (foundation.defaultTargetPlatform == TargetPlatform.iOS ? 1.20 : 1.0), - backgroundColor: LAYER_1, - ), - viewOrderConfig: const ViewOrderConfig( - top: EmojiPickerItem.categoryBar, - middle: EmojiPickerItem.emojiView, - bottom: EmojiPickerItem.searchBar, - ), - bottomActionBarConfig: BottomActionBarConfig( - backgroundColor: LAYER_2, - buttonColor: PRIMARY_1, - showBackspaceButton: false, - ), - categoryViewConfig: CategoryViewConfig(backgroundColor: LAYER_2), - searchViewConfig: SearchViewConfig( - backgroundColor: LAYER_2, - buttonIconColor: PRIMARY_1, - ), -); +import 'package:zap_stream_flutter/widgets/emoji.dart'; class ReactionWidget extends StatelessWidget { final Nip01Event event; @@ -38,15 +11,11 @@ class ReactionWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return EmojiPicker( - onEmojiSelected: (category, emoji) { - ndk.broadcast.broadcastReaction( - eventId: event.id, - reaction: emoji.emoji, - ); - Navigator.pop(context); + return EmojiPickerCustom( + onEmojiSelected: (e) { + ndk.broadcast.broadcastReaction(eventId: event.id, reaction: e.emoji); + context.pop(); }, - config: emojiPickerConfig, ); } } diff --git a/pubspec.lock b/pubspec.lock index c5de158..cf0b189 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -220,10 +220,11 @@ packages: emoji_picker_flutter: dependency: "direct main" description: - name: emoji_picker_flutter - sha256: "9a44c102079891ea5877f78c70f2e3c6e9df7b7fe0a01757d31f1046eeaa016d" - url: "https://pub.dev" - source: hosted + path: "." + ref: HEAD + resolved-ref: "21a71ffed243ce88c625723d8f3ae0281a480b04" + url: "https://github.com/nostrlabs-io/emoji_picker_flutter" + source: git version: "4.3.0" equatable: dependency: transitive @@ -721,8 +722,8 @@ packages: dependency: "direct main" description: path: "packages/ndk" - ref: "9076bb42ed927314249165e59c7c3428268ce889" - resolved-ref: "9076bb42ed927314249165e59c7c3428268ce889" + ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" + resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" url: "https://github.com/relaystr/ndk" source: git version: "0.3.2" @@ -730,8 +731,8 @@ packages: dependency: "direct main" description: path: "packages/amber" - ref: "9076bb42ed927314249165e59c7c3428268ce889" - resolved-ref: "9076bb42ed927314249165e59c7c3428268ce889" + ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" + resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" url: "https://github.com/relaystr/ndk" source: git version: "0.3.0" @@ -739,8 +740,8 @@ packages: dependency: "direct main" description: path: "packages/objectbox" - ref: "9076bb42ed927314249165e59c7c3428268ce889" - resolved-ref: "9076bb42ed927314249165e59c7c3428268ce889" + ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" + resolved-ref: "6242899ee4ff7f65e57518d4eb29e2a253b3c4da" url: "https://github.com/relaystr/ndk" source: git version: "0.2.3" diff --git a/pubspec.yaml b/pubspec.yaml index 7c4c01b..7b3c4ab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,9 @@ dependency_overrides: url: https://github.com/relaystr/ndk path: packages/amber ref: 6242899ee4ff7f65e57518d4eb29e2a253b3c4da + emoji_picker_flutter: + git: + url: https://github.com/nostrlabs-io/emoji_picker_flutter dev_dependencies: flutter_test: