From 0813597a5360ca323991d1cc785434644abc6ecd Mon Sep 17 00:00:00 2001 From: istalri Date: Sat, 25 Jul 2026 19:04:56 +0200 Subject: [PATCH 1/2] Small refactor of emoji controller and widget Isolated deserialization of the large emoji json to not keep main thread busy to long Refactored textController logic in EmojiPickerButton to make popping mid buid impossible. The bug from the comment should be impossible now (The bug could be reproduce with future.delayed and microtask but) --- lib/controllers/emoji.dart | 8 +++- lib/widgets/emoji_picker_button.dart | 60 +++++++++++++++++----------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/lib/controllers/emoji.dart b/lib/controllers/emoji.dart index caea3de..0c89896 100644 --- a/lib/controllers/emoji.dart +++ b/lib/controllers/emoji.dart @@ -1,6 +1,7 @@ import "dart:convert"; import "package:emoji_text_field/models/emoji_category.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:http/http.dart"; @@ -19,9 +20,12 @@ class EmojiController extends AsyncNotifier { throw Exception("Failed to load emoji data"); } - final data = json.decode(response.body); + return compute(_parseEmojiJson, response.body); + } - final entries = (data as List) + static EmojiTuple _parseEmojiJson(String body) { + final data = json.decode(body) as List; + final entries = data .cast>() .map(Emoji.fromJson) .toIList(); diff --git a/lib/widgets/emoji_picker_button.dart b/lib/widgets/emoji_picker_button.dart index 2ac906a..3516590 100644 --- a/lib/widgets/emoji_picker_button.dart +++ b/lib/widgets/emoji_picker_button.dart @@ -20,32 +20,46 @@ class EmojiPickerButton extends HookConsumerWidget { Widget build(_, WidgetRef ref) => IconButton( onPressed: () async { onPressed?.call(); - final controller = this.controller ?? .new(); + + final tempController = controller ?? TextEditingController(); + final shouldDispose = controller == null; + var hasPopped = false; + + void handler() { + if (tempController.text.isEmpty || hasPopped) return; + hasPopped = true; + + onSelection?.call(tempController.text); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) Navigator.of(context).maybePop(); + }); + } final emojis = await ref.watch(EmojiController.provider.future); - if (context.mounted) { - showModalBottomSheet( - context: context, - builder: (context) => EmojiKeyboardView( - config: .new( - showRecentTab: false, - customCategories: emojis.$1.unlock, - customKeywords: emojis.$2.unlock, - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - height: 600, - ), - textController: controller - ..addListener(() { - // Without this, there will sometimes be a debugLocked is not true error sometimes - // It might be preferable to use a microtask instead of a `Future.delayed`. - Future.delayed(.zero, () { - if (context.mounted) Navigator.of(context).pop(); - }); - onSelection?.call(controller.text); - }), - ), - ); + if (!context.mounted) { + if (shouldDispose) tempController.dispose(); + return; } + + await showModalBottomSheet( + context: context, + builder: (sheetContext) => EmojiKeyboardView( + config: .new( + showRecentTab: false, + customCategories: emojis.$1.unlock, + customKeywords: emojis.$2.unlock, + backgroundColor: Theme.of( + sheetContext, + ).colorScheme.surfaceContainer, + height: 600, + ), + textController: tempController..addListener(handler), + ), + ); + + tempController.removeListener(handler); + if (shouldDispose) tempController.dispose(); }, icon: Icon(Icons.emoji_emotions), ); From 941e1c0e69aa1aa40936d4c63266fa9909a5e711 Mon Sep 17 00:00:00 2001 From: istalri Date: Sat, 1 Aug 2026 00:58:57 +0200 Subject: [PATCH 2/2] WIP: New Emoji Picker --- gomuks | 2 +- lib/controllers/emoji.dart | 145 +++++---- lib/models/{emoji.dart => gemoji.dart} | 12 +- lib/widgets/composer/composer.dart | 35 ++- lib/widgets/emoji_picker_button.dart | 66 ---- lib/widgets/emote_picker.dart | 399 +++++++++++++++++++++++++ lib/widgets/room_chat.dart | 24 +- 7 files changed, 543 insertions(+), 140 deletions(-) rename lib/models/{emoji.dart => gemoji.dart} (59%) delete mode 100644 lib/widgets/emoji_picker_button.dart create mode 100644 lib/widgets/emote_picker.dart diff --git a/gomuks b/gomuks index 1f11743..c06eb1c 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 1f11743884a64765b1444b250377a09ebcaba93c +Subproject commit c06eb1cbda2d49ff199a351e2d55df6ff7ab602b diff --git a/lib/controllers/emoji.dart b/lib/controllers/emoji.dart index 0c89896..f1a5c79 100644 --- a/lib/controllers/emoji.dart +++ b/lib/controllers/emoji.dart @@ -1,17 +1,47 @@ import "dart:convert"; -import "package:emoji_text_field/models/emoji_category.dart"; +import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:http/http.dart"; -import "package:nexus/models/emoji.dart"; +import "package:nexus/models/gemoji.dart"; -typedef EmojiTuple = (IMap, IMap>); +class EmoteSection { + final String category; + final String label; + final IconData icon; + final List items; + final bool isRecent; -class EmojiController extends AsyncNotifier { + const EmoteSection({ + required this.category, + required this.label, + required this.icon, + required this.items, + this.isRecent = false, + }); +} + +class EmoteItem { + final String id; + final String display; + final List keywords; + final int useCounter; + final Image? customImage; + + const EmoteItem({ + required this.id, + required this.display, + required this.keywords, + required this.useCounter, + this.customImage, + }); +} + +class EmojiController extends AsyncNotifier> { @override - Future build() async { + Future> build() async { final response = await get( .https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"), ); @@ -20,69 +50,72 @@ class EmojiController extends AsyncNotifier { throw Exception("Failed to load emoji data"); } - return compute(_parseEmojiJson, response.body); + return compute(parseEmojiJson, response.body); } - static EmojiTuple _parseEmojiJson(String body) { + static IList parseEmojiJson(String body) { final data = json.decode(body) as List; - final entries = data + final allEntries = data .cast>() - .map(Emoji.fromJson) + .map(Gemoji.fromJson) .toIList(); - final categoryMap = entries.fold>>( - .new(), - (acc, entry) => acc.update( - entry.category, - (list) => list.add(entry.emoji), - ifAbsent: () => .new([entry.emoji]), - ), - ); + final groupedByCategory = groupBy(allEntries, (g) => g.category); - final keywordMap = entries.fold>>( + final result = groupedByCategory.entries.fold>( .new(), - (acc, entry) => acc.add( - entry.emoji, - .new([...entry.tags, ...entry.aliases, entry.description]), - ), - ); - - final customCategories = IMap.fromEntries( - categoryMap.entries.map( - (entry) => MapEntry( - entry.key, - EmojiCategory( - name: entry.key, - icon: switch (entry.key) { - "Smileys & Emotion" => Icons.emoji_emotions, - "People & Body" => Icons.emoji_people, - "Animals & Nature" => Icons.emoji_nature, - "Food & Drink" => Icons.emoji_food_beverage, - "Travel & Places" => Icons.travel_explore, - "Activities" => Icons.sports_soccer, - "Objects" => Icons.emoji_objects, - "Symbols" => Icons.emoji_symbols, - "Flags" => Icons.emoji_flags, - _ => Icons.category, - }, - emojis: entry.value.toList(growable: false), - ), + (resultList, entry) => resultList.add( + EmoteSection( + category: entry.key, + label: entry.key, + icon: getIconForCategory(entry.key), + items: entry.value + .map( + (gemoji) => EmoteItem( + id: gemoji.emoji, + display: gemoji.emoji, + keywords: [ + ...gemoji.tags, + ...gemoji.aliases, + ...gemoji.description.split(" "), + ], + useCounter: 0, + ), + ) + .toList(), + isRecent: false, ), ), ); - final customKeywords = IMap( - .fromEntries( - keywordMap.entries.map( - (e) => .new(e.key, e.value.toList(growable: false)), - ), - ), - ); - - return (customCategories, customKeywords); + return result; } - static final provider = AsyncNotifierProvider( - EmojiController.new, - ); + static final provider = + AsyncNotifierProvider>( + EmojiController.new, + ); + + static IconData getIconForCategory(String category) { + switch (category.toLowerCase()) { + case "smileys & emotion": + return Icons.sentiment_very_satisfied; + case "people & body": + return Icons.face; + case "food & drink": + return Icons.restaurant; + case "travel & places": + return Icons.flag; + case "activities": + return Icons.sports_soccer; + case "objects": + return Icons.lightbulb; + case "symbols": + return Icons.circle; + case "flags": + return Icons.flag; + default: + return Icons.emoji_emotions_outlined; + } + } } diff --git a/lib/models/emoji.dart b/lib/models/gemoji.dart similarity index 59% rename from lib/models/emoji.dart rename to lib/models/gemoji.dart index 8e4eac6..084a1ca 100644 --- a/lib/models/emoji.dart +++ b/lib/models/gemoji.dart @@ -1,17 +1,17 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -part "emoji.freezed.dart"; -part "emoji.g.dart"; +part "gemoji.freezed.dart"; +part "gemoji.g.dart"; @freezed -abstract class Emoji with _$Emoji { - const factory Emoji({ +abstract class Gemoji with _$Gemoji { + const factory Gemoji({ required String emoji, required String category, required IList aliases, required String description, required IList tags, - }) = _Emoji; + }) = _Gemoji; - factory Emoji.fromJson(Map json) => _$EmojiFromJson(json); + factory Gemoji.fromJson(Map json) => _$GemojiFromJson(json); } diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index c63d939..281db0d 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -14,8 +14,8 @@ import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/mention_overlay.dart"; import "package:nexus/widgets/composer/relation_preview.dart"; -import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/main.dart"; +import "package:nexus/widgets/emote_picker.dart"; class Composer extends HookConsumerWidget { final String roomId; @@ -123,10 +123,35 @@ class Composer extends HookConsumerWidget { ), ) ? [ - EmojiPickerButton( - context: context, - onSelection: (_) => node?.requestFocus(), - controller: controller.value, + IconButton( + icon: Icon(Icons.emoji_emotions), + tooltip: "Insert emoji", + onPressed: () { + showEmotePickerBottomSheet( + context: context, + roomId: roomId, + onSelect: (emojiId) { + final textController = controller.value; + final selection = textController.selection; + final newText = textController.text + .replaceRange( + selection.start, + selection.end, + emojiId, + ); + + textController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: + selection.start + emojiId.length, + ), + ); + + node?.requestFocus(); + }, + ); + }, ), PopupMenuButton( tooltip: "Add media", diff --git a/lib/widgets/emoji_picker_button.dart b/lib/widgets/emoji_picker_button.dart deleted file mode 100644 index 3516590..0000000 --- a/lib/widgets/emoji_picker_button.dart +++ /dev/null @@ -1,66 +0,0 @@ -import "package:emoji_text_field/emoji_text_field.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/emoji.dart"; - -class EmojiPickerButton extends HookConsumerWidget { - final TextEditingController? controller; - final void Function(String emoji)? onSelection; - final VoidCallback? onPressed; - final BuildContext context; - const EmojiPickerButton({ - this.controller, - this.onPressed, - this.onSelection, - required this.context, - super.key, - }); - - @override - Widget build(_, WidgetRef ref) => IconButton( - onPressed: () async { - onPressed?.call(); - - final tempController = controller ?? TextEditingController(); - final shouldDispose = controller == null; - var hasPopped = false; - - void handler() { - if (tempController.text.isEmpty || hasPopped) return; - hasPopped = true; - - onSelection?.call(tempController.text); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) Navigator.of(context).maybePop(); - }); - } - - final emojis = await ref.watch(EmojiController.provider.future); - if (!context.mounted) { - if (shouldDispose) tempController.dispose(); - return; - } - - await showModalBottomSheet( - context: context, - builder: (sheetContext) => EmojiKeyboardView( - config: .new( - showRecentTab: false, - customCategories: emojis.$1.unlock, - customKeywords: emojis.$2.unlock, - backgroundColor: Theme.of( - sheetContext, - ).colorScheme.surfaceContainer, - height: 600, - ), - textController: tempController..addListener(handler), - ), - ); - - tempController.removeListener(handler); - if (shouldDispose) tempController.dispose(); - }, - icon: Icon(Icons.emoji_emotions), - ); -} diff --git a/lib/widgets/emote_picker.dart b/lib/widgets/emote_picker.dart new file mode 100644 index 0000000..881526c --- /dev/null +++ b/lib/widgets/emote_picker.dart @@ -0,0 +1,399 @@ +import "dart:math" as math; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:flutter/rendering.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/emoji.dart"; + +class EmotePicker extends HookConsumerWidget { + final String? roomId; + final void Function(String emoteId) onSelect; + final VoidCallback? onClose; + + const EmotePicker({ + super.key, + this.roomId, + this.onClose, + required this.onSelect, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + minHeight: math.min(MediaQuery.of(context).size.height * 0.6, 400), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Divider(height: 1), + // Content + Expanded( + child: ListView( + children: [ + EmoteCategoryList( + roomId: roomId, + onSelect: onSelect, + fetchSections: (rid, query) => + fetchEmojiSections(ref, rid, query), + placeholderText: "emojis & emotes", + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class EmoteCategoryList extends HookWidget { + final String? roomId; + final void Function(String) onSelect; + final Future> Function(String? roomId, String query) + fetchSections; + final String placeholderText; + + const EmoteCategoryList({ + super.key, + required this.roomId, + required this.onSelect, + required this.fetchSections, + required this.placeholderText, + }); + + @override + Widget build(BuildContext context) { + final searchController = useTextEditingController(text: ""); + final searchFocus = useFocusNode(); + final searchText = useState(""); + + useEffect(() { + void listener() { + searchText.value = searchController.text; + } + + searchController.addListener(listener); + return () => searchController.removeListener(listener); + }, [searchController]); + + final isSearching = searchText.value.isNotEmpty; + final sectionsFuture = useMemoized( + () => fetchSections(roomId, searchText.value), + [searchText.value], + ); + final sectionsSnapshot = useFuture(sectionsFuture); + final sections = sectionsSnapshot.data ?? [].toIList(); + + final scrollController = useScrollController(); + + final sectionKeysMap = useState({}); + + final activeCategoryId = useState(null); + + useEffect(() { + if (sections.isEmpty) return null; + + bool active = true; + void listener() { + if (!active || !scrollController.hasClients) return; + + final scrollPixels = scrollController.offset; + String? current; + + for (final section in sections) { + final key = sectionKeysMap.value.putIfAbsent( + section.category, + () => GlobalKey(), + ); + if (key.currentContext?.findRenderObject() == null) continue; + + final ro = key.currentContext!.findRenderObject()!; + if (!ro.attached) continue; + + try { + final vp = RenderAbstractViewport.of(ro); + final reveal = vp.getOffsetToReveal(ro, 0.0).offset; + + if (reveal <= scrollPixels + 80) { + current = section.category; + } else { + break; + } + } catch (_) { + continue; + } + } + + if (current != null && current != activeCategoryId.value) { + activeCategoryId.value = current; + } + } + + scrollController.addListener(listener); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (active) listener(); + }); + + return () { + active = false; + scrollController.removeListener(listener); + }; + }, [sections, scrollController]); + + return Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: TextField( + controller: searchController, + focusNode: searchFocus, + decoration: InputDecoration( + hintText: "Search $placeholderText...", + prefixIcon: Icon(Icons.search), + suffixIcon: isSearching + ? IconButton( + icon: Icon(Icons.clear), + onPressed: () { + searchController.clear(); + searchFocus.requestFocus(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + Expanded( + child: Row( + children: [ + if (!isSearching) ...[ + SizedBox( + width: 100, + child: ListView.builder( + itemCount: sections.length, + itemBuilder: (context, index) { + final section = sections[index]; + final isActive = + activeCategoryId.value == section.category; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + activeCategoryId.value = section.category; + final key = sectionKeysMap.value[section.category]; + if (key?.currentContext != null) { + Scrollable.ensureVisible( + key!.currentContext!, + alignment: 0.0, + duration: const Duration(milliseconds: 250), + ); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + width: 2, + color: isActive + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + ), + ), + color: isActive + ? Theme.of(context).colorScheme.primary + .withValues(alpha: 0.08) + : Colors.transparent, + ), + child: Text( + section.isRecent ? "Recent" : section.label, + style: TextStyle( + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : null, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + }, + ), + ), + const VerticalDivider(width: 1), + ], + Expanded( + child: + sectionsSnapshot.connectionState == ConnectionState.waiting + ? const Center(child: CircularProgressIndicator()) + : sections.isEmpty + ? Center(child: Text("No $placeholderText found")) + : CustomScrollView( + controller: scrollController, + slivers: [ + for (final section in sections) ...[ + SliverToBoxAdapter( + key: sectionKeysMap.value.putIfAbsent( + section.category, + () => GlobalKey(), + ), + child: Container( + padding: const EdgeInsets.only( + left: 12, + top: 12, + bottom: 8, + ), + child: Text( + section.isRecent + ? "Recently Used" + : section.label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 44, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final item = section.items[index]; + return GestureDetector( + onTap: () => onSelect(item.id), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 6, + ), + ), + child: Text( + item.display, + style: const TextStyle(fontSize: 24), + ), + ), + ); + }, childCount: section.items.length), + ), + ), + ], + const SliverPadding( + padding: EdgeInsets.only(bottom: 24), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +Future> fetchEmojiSections( + WidgetRef ref, + String? roomId, + String query, +) async { + final emotes = await ref.watch(EmojiController.provider.future); + final q = query.toLowerCase(); + + if (q.isEmpty) { + return emotes; + } + + return emotes + .map((cat) { + final filtered = cat.items + .where( + (e) => + e.keywords.any((k) => k.contains(q)) || e.id.contains(query), + ) + .toList(); + return filtered.isNotEmpty + ? EmoteSection( + category: cat.category, + label: cat.label, + icon: cat.icon, + items: filtered, + isRecent: cat.isRecent, + ) + : null; + }) + .whereType() + .toIList(); +} + +Future showEmotePickerBottomSheet({ + required BuildContext context, + String? roomId, + required void Function(String) onSelect, +}) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + useSafeArea: true, + enableDrag: true, + isDismissible: true, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + maxWidth: 600, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => EmotePicker(roomId: roomId, onSelect: onSelect), + ); +} diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 0fdd7c7..f679cf6 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -16,7 +16,7 @@ import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/composer.dart"; -import "package:nexus/widgets/emoji_picker_button.dart"; +import "package:nexus/widgets/emote_picker.dart"; import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; @@ -239,11 +239,23 @@ class RoomChat extends HookConsumerWidget { icon: Text(emoji), ), ), - EmojiPickerButton( - context: context, - onPressed: Navigator.of(context).pop, - onSelection: (emoji) => - notifier.sendReaction(emoji, event).onError(showError), + GestureDetector( + onTap: () { + Navigator.of(context).pop(); // Close popup menu + showEmotePickerBottomSheet( + context: context, + roomId: roomId, + onSelect: (emojiId) async { + await notifier + .sendReaction(emojiId, event) + .onError(showError); + }, + ); + }, + child: Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.emoji_emotions), + ), ), ], ),