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), + ), ), ], ),