diff --git a/lib/pages/chat/chat_emoji_picker.dart b/lib/pages/chat/chat_emoji_picker.dart index 069c4fc1..aeabf1fa 100644 --- a/lib/pages/chat/chat_emoji_picker.dart +++ b/lib/pages/chat/chat_emoji_picker.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:fluffychat/utils/fluffy_emoji_picker.dart'; import 'chat.dart'; class ChatEmojiPicker extends StatelessWidget { @@ -19,6 +20,7 @@ class ChatEmojiPicker extends StatelessWidget { ? EmojiPicker( onEmojiSelected: controller.onEmojiSelected, onBackspacePressed: controller.emojiPickerBackspace, + customWidget: (c, s) => FluffyEmojiPickerView(c, s), ) : null, ); diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index b2ffb156..8d8dcddb 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:emojis/emoji.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:matrix/matrix.dart'; @@ -54,7 +55,7 @@ class InputBar extends StatelessWidget { final List> ret = >[]; const maxResults = 30; - final commandMatch = RegExp(r'^\/([\w]*)$').firstMatch(searchText); + final commandMatch = RegExp(r'^/(\w*)$').firstMatch(searchText); if (commandMatch != null) { final commandSearch = commandMatch[1]!.toLowerCase(); for (final command in room.client.commands.keys) { @@ -114,6 +115,39 @@ class InputBar extends StatelessWidget { } } } + // aside of emote packs, also propose normal (tm) unicode emojis + final matchingUnicodeEmojis = Emoji.all() + .where((element) => [element.name, ...element.keywords] + .any((element) => element.toLowerCase().contains(emoteSearch))) + .toList(); + // sort by the index of the search term in the name in order to have + // best matches first + // (thanks for the hint by github.com/nextcloud/circles devs) + matchingUnicodeEmojis.sort((a, b) { + final indexA = a.name.indexOf(emoteSearch); + final indexB = b.name.indexOf(emoteSearch); + if (indexA == -1 || indexB == -1) { + if (indexA == indexB) return 0; + if (indexA == -1) { + return 1; + } else { + return 0; + } + } + return indexA.compareTo(indexB); + }); + for (final emoji in matchingUnicodeEmojis) { + ret.add({ + 'type': 'emoji', + 'emoji': emoji.char, + // don't include sub-group names, splitting at `:` hence + 'label': '${emoji.char} - ${emoji.name.split(':').first}', + 'current_word': ':$emoteSearch', + }); + if (ret.length > maxResults) { + break; + } + } } final userMatch = RegExp(r'(?:\s|^)@([-\w]+)$').firstMatch(searchText); if (userMatch != null) { @@ -205,6 +239,17 @@ class InputBar extends StatelessWidget { ), ); } + if (suggestion['type'] == 'emoji') { + final label = suggestion['label']!; + return Tooltip( + message: label, + waitDuration: const Duration(days: 1), // don't show on hover + child: Container( + padding: padding, + child: Text(label, style: const TextStyle(fontFamily: 'monospace')), + ), + ); + } if (suggestion['type'] == 'emote') { final ratio = MediaQuery.of(context).devicePixelRatio; final url = Uri.parse(suggestion['mxc'] ?? '').getThumbnail( @@ -282,10 +327,17 @@ class InputBar extends StatelessWidget { if (suggestion['type'] == 'command') { insertText = suggestion['name']! + ' '; startText = replaceText.replaceAllMapped( - RegExp(r'^(\/[\w]*)$'), + RegExp(r'^(/\w*)$'), (Match m) => '/' + insertText, ); } + if (suggestion['type'] == 'emoji') { + insertText = suggestion['emoji']! + ' '; + startText = replaceText.replaceAllMapped( + suggestion['current_word']!, + (Match m) => insertText, + ); + } if (suggestion['type'] == 'emote') { var isUnique = true; final insertEmote = suggestion['name']; @@ -376,10 +428,8 @@ class InputBar extends StatelessWidget { hideOnEmpty: true, hideOnLoading: true, keepSuggestionsOnSuggestionSelected: true, - - debounceDuration: const Duration( - milliseconds: - 50), // show suggestions after 50ms idle time (default is 300) + debounceDuration: const Duration(milliseconds: 50), + // show suggestions after 50ms idle time (default is 300) textFieldConfiguration: TextFieldConfiguration( minLines: minLines, maxLines: maxLines, @@ -407,8 +457,8 @@ class InputBar extends StatelessWidget { onSuggestionSelected: (Map suggestion) => insertSuggestion(context, suggestion), errorBuilder: (BuildContext context, Object? error) => Container(), - loadingBuilder: (BuildContext context) => - Container(), // fix loading briefly flickering a dark box + loadingBuilder: (BuildContext context) => Container(), + // fix loading briefly flickering a dark box noItemsFoundBuilder: (BuildContext context) => Container(), // fix loading briefly showing no suggestions ), diff --git a/lib/pages/chat/reactions_picker.dart b/lib/pages/chat/reactions_picker.dart index ab70d548..7d26e4e5 100644 --- a/lib/pages/chat/reactions_picker.dart +++ b/lib/pages/chat/reactions_picker.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:emoji_proposal/emoji_proposal.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; @@ -8,6 +9,7 @@ import 'package:fluffychat/pages/chat/chat.dart'; class ReactionsPicker extends StatelessWidget { final ChatController controller; + const ReactionsPicker(this.controller, {Key? key}) : super(key: key); @override @@ -26,7 +28,13 @@ class ReactionsPicker extends StatelessWidget { if (!display) { return Container(); } - final emojis = List.from(AppEmojis.emojis); + final proposals = proposeEmojis( + controller.selectedEvents.first.plaintextBody, + number: 25, + languageCodes: EmojiProposalLanguageCodes.values.toSet()); + final emojis = proposals.isNotEmpty + ? proposals.map((e) => e.char).toList() + : List.from(AppEmojis.emojis); final allReactionEvents = controller.selectedEvents.first .aggregatedEvents( controller.timeline!, RelationshipTypes.reaction) diff --git a/lib/utils/fluffy_emoji_picker.dart b/lib/utils/fluffy_emoji_picker.dart new file mode 100644 index 00000000..c4fd26d8 --- /dev/null +++ b/lib/utils/fluffy_emoji_picker.dart @@ -0,0 +1,398 @@ +// ignore_for_file: prefer_function_declarations_over_variables, implementation_imports + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:emoji_picker_flutter/src/category_emoji.dart'; +import 'package:emoji_picker_flutter/src/emoji_picker_internal_utils.dart'; +import 'package:emoji_picker_flutter/src/emoji_skin_tones.dart'; +import 'package:emoji_picker_flutter/src/emoji_view_state.dart'; +import 'package:emoji_picker_flutter/src/triangle_shape.dart'; +import 'package:emojis/emoji.dart' as emoji; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/widgets/matrix.dart'; + +/// Default EmojiPicker Implementation - adjusted for FluffyChat +/// +/// Copied and adjusted from: [DefaultEmojiPickerView] +class FluffyEmojiPickerView extends EmojiPickerBuilder { + /// Constructor + FluffyEmojiPickerView(Config config, EmojiViewState state) + : super(config, state); + + @override + _DefaultEmojiPickerViewState createState() => _DefaultEmojiPickerViewState(); +} + +class _DefaultEmojiPickerViewState extends State + with SingleTickerProviderStateMixin { + PageController? _pageController; + TabController? _tabController; + OverlayEntry? _overlay; + late final _scrollController = ScrollController(); + late final _utils = EmojiPickerInternalUtils(); + final int _skinToneCount = 6; + final double tabBarHeight = 46; + + @override + void initState() { + var initCategory = widget.state.categoryEmoji.indexWhere( + (element) => element.category == widget.config.initCategory); + if (initCategory == -1) { + initCategory = 0; + } + _tabController = TabController( + initialIndex: initCategory, + length: widget.state.categoryEmoji.length, + vsync: this); + _pageController = PageController(initialPage: initCategory) + ..addListener(_closeSkinToneDialog); + _scrollController.addListener(_closeSkinToneDialog); + super.initState(); + } + + @override + void dispose() { + _closeSkinToneDialog(); + super.dispose(); + } + + void _closeSkinToneDialog() { + _overlay?.remove(); + _overlay = null; + } + + void _openSkinToneDialog( + Emoji emoji, + double emojiSize, + CategoryEmoji categoryEmoji, + int index, + ) { + _overlay = _buildSkinToneOverlay( + emoji, + emojiSize, + categoryEmoji, + index, + ); + Overlay.of(context)?.insert(_overlay!); + } + + Widget _buildBackspaceButton() { + if (widget.state.onBackspacePressed != null) { + return Material( + type: MaterialType.transparency, + child: IconButton( + padding: const EdgeInsets.only(bottom: 2), + icon: Icon( + Icons.backspace, + color: widget.config.backspaceColor, + ), + onPressed: () { + widget.state.onBackspacePressed!(); + }), + ); + } + return Container(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final emojiSize = widget.config.getEmojiSize(constraints.maxWidth); + + return Container( + color: widget.config.bgColor, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: SizedBox( + height: tabBarHeight, + child: TabBar( + labelColor: widget.config.iconColorSelected, + indicatorColor: widget.config.indicatorColor, + unselectedLabelColor: widget.config.iconColor, + controller: _tabController, + labelPadding: EdgeInsets.zero, + onTap: (index) { + _closeSkinToneDialog(); + _pageController!.jumpToPage(index); + }, + tabs: widget.state.categoryEmoji + .asMap() + .entries + .map((item) => + _buildCategory(item.key, item.value.category)) + .toList(), + ), + ), + ), + _buildBackspaceButton(), + ], + ), + Flexible( + child: PageView.builder( + itemCount: widget.state.categoryEmoji.length, + controller: _pageController, + onPageChanged: (index) { + _tabController!.animateTo( + index, + duration: widget.config.tabIndicatorAnimDuration, + ); + }, + itemBuilder: (context, index) => + _buildPage(emojiSize, widget.state.categoryEmoji[index]), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildCategory(int index, Category category) { + return Tab( + icon: Icon( + widget.config.getIconForCategory(category), + ), + ); + } + + Widget _buildPage(double emojiSize, CategoryEmoji categoryEmoji) { + final matrix = Matrix.of(context); + + // Display notice if recent has no entries yet + if (categoryEmoji.category == Category.RECENT) { + final recent = matrix.client.recentEmojis; + final sorted = recent.keys.toList() + ..sort((a, b) => recent[b]!.compareTo(recent[a]!)); + categoryEmoji.emoji = sorted + .map((char) => Emoji(emoji.Emoji.byChar(char)?.name ?? '', char)) + .toList(); + if (categoryEmoji.emoji.isEmpty) { + return _buildNoRecent(); + } + } + // Build page normally + return GestureDetector( + onTap: _closeSkinToneDialog, + child: GridView.count( + scrollDirection: Axis.vertical, + physics: const ScrollPhysics(), + controller: _scrollController, + shrinkWrap: true, + primary: false, + padding: const EdgeInsets.all(0), + crossAxisCount: widget.config.columns, + mainAxisSpacing: widget.config.verticalSpacing, + crossAxisSpacing: widget.config.horizontalSpacing, + children: categoryEmoji.emoji.asMap().entries.map((item) { + final index = item.key; + final emoji = item.value; + final onPressed = () { + _closeSkinToneDialog(); + matrix.client.addRecentEmoji(emoji.emoji); + widget.state.onEmojiSelected(categoryEmoji.category, emoji); + }; + + final onLongPressed = () { + if (!emoji.hasSkinTone || !widget.config.enableSkinTones) { + _closeSkinToneDialog(); + return; + } + _closeSkinToneDialog(); + _openSkinToneDialog(emoji, emojiSize, categoryEmoji, index); + }; + + return _buildButtonWidget( + onPressed: onPressed, + onLongPressed: onLongPressed, + child: _buildEmoji( + emojiSize, + categoryEmoji, + emoji, + widget.config.enableSkinTones, + ), + ); + }).toList(), + ), + ); + } + + /// Build and display Emoji centered of its parent + Widget _buildEmoji( + double emojiSize, + CategoryEmoji categoryEmoji, + Emoji emoji, + bool showSkinToneIndicator, + ) { + // FittedBox needed for display, font scale settings + return FittedBox( + fit: BoxFit.fill, + child: Stack(children: [ + emoji.hasSkinTone && showSkinToneIndicator + ? Positioned( + bottom: 0, + right: 0, + child: CustomPaint( + size: const Size(8, 8), + painter: TriangleShape(widget.config.skinToneIndicatorColor), + ), + ) + : Container(), + Text( + emoji.emoji, + textScaleFactor: 1.0, + style: TextStyle( + fontSize: emojiSize, + backgroundColor: Colors.transparent, + ), + ), + ]), + ); + } + + /// Build different Button based on ButtonMode + Widget _buildButtonWidget({ + required VoidCallback onPressed, + required VoidCallback onLongPressed, + required Widget child, + }) { + if (widget.config.buttonMode == ButtonMode.MATERIAL) { + return TextButton( + onPressed: onPressed, + onLongPress: onLongPressed, + child: child, + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + minimumSize: MaterialStateProperty.all(Size.zero), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } + return GestureDetector( + onLongPress: onLongPressed, + child: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: onPressed, + child: child, + ), + ); + } + + /// Build Widget for when no recent emoji are available + Widget _buildNoRecent() { + return Center( + child: Text( + widget.config.noRecentsText, + style: widget.config.noRecentsStyle, + textAlign: TextAlign.center, + )); + } + + /// Overlay for SkinTone + OverlayEntry _buildSkinToneOverlay( + Emoji emoji, + double emojiSize, + CategoryEmoji categoryEmoji, + int index, + ) { + // Calculate position of emoji in the grid + final row = index ~/ widget.config.columns; + final column = index % widget.config.columns; + // Calculate position for skin tone dialog + final renderBox = context.findRenderObject() as RenderBox; + final offset = renderBox.localToGlobal(Offset.zero); + final emojiSpace = renderBox.size.width / widget.config.columns; + final topOffset = emojiSpace; + final leftOffset = _getLeftOffset(emojiSpace, column); + final left = offset.dx + column * emojiSpace + leftOffset; + final top = tabBarHeight + + offset.dy + + row * emojiSpace - + _scrollController.offset - + topOffset; + + // Generate other skintone options + final skinTonesEmoji = SkinTone.values + .map((skinTone) => _utils.applySkinTone(emoji, skinTone)) + .toList(); + + return OverlayEntry( + builder: (context) => Positioned( + left: left, + top: top, + child: Material( + elevation: 4.0, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4.0), + color: widget.config.skinToneDialogBgColor, + child: Row( + children: [ + _buildSkinToneEmoji( + categoryEmoji, emoji, emojiSpace, emojiSize), + _buildSkinToneEmoji( + categoryEmoji, skinTonesEmoji[0], emojiSpace, emojiSize), + _buildSkinToneEmoji( + categoryEmoji, skinTonesEmoji[1], emojiSpace, emojiSize), + _buildSkinToneEmoji( + categoryEmoji, skinTonesEmoji[2], emojiSpace, emojiSize), + _buildSkinToneEmoji( + categoryEmoji, skinTonesEmoji[3], emojiSpace, emojiSize), + _buildSkinToneEmoji( + categoryEmoji, skinTonesEmoji[4], emojiSpace, emojiSize), + ], + ), + ), + ), + ), + ); + } + + // Build Emoji inside skin tone dialog + Widget _buildSkinToneEmoji( + CategoryEmoji categoryEmoji, + Emoji emoji, + double width, + double emojiSize, + ) { + return SizedBox( + width: width, + height: width, + child: _buildButtonWidget( + onPressed: () { + widget.state.onEmojiSelected(categoryEmoji.category, emoji); + _closeSkinToneDialog(); + }, + onLongPressed: () {}, + child: _buildEmoji(emojiSize, categoryEmoji, emoji, false), + ), + ); + } + + // Calucates the offset from the middle of selected emoji to the left side + // of the skin tone dialog + // Case 1: Selected Emoji is close to left border and offset needs to be + // reduced + // Case 2: Selected Emoji is close to right border and offset needs to be + // larger than half of the whole width + // Case 3: Enough space to left and right border and offset can be half + // of whole width + double _getLeftOffset(double emojiWidth, int column) { + final remainingColumns = + widget.config.columns - (column + 1 + (_skinToneCount ~/ 2)); + if (column >= 0 && column < 3) { + return -1 * column * emojiWidth; + } else if (remainingColumns < 0) { + return -1 * + ((_skinToneCount ~/ 2 - 1) + -1 * remainingColumns) * + emojiWidth; + } + return -1 * ((_skinToneCount ~/ 2) * emojiWidth) + emojiWidth / 2; + } +} diff --git a/pubspec.lock b/pubspec.lock index 2f683c27..ff9527dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -388,6 +388,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.2" + emoji_proposal: + dependency: "direct main" + description: + name: emoji_proposal + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1" + emojis: + dependency: "direct main" + description: + name: emojis + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.9" encrypt: dependency: "direct main" description: @@ -1416,6 +1430,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.3" + remove_emoji: + dependency: transitive + description: + name: remove_emoji + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.7" rxdart: dependency: transitive description: @@ -1437,6 +1458,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.3.2" + sentiment_dart: + dependency: transitive + description: + name: sentiment_dart + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.5" sentry: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index c26454a8..402e1b77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,8 @@ dependencies: dynamic_color: ^1.2.2 email_validator: ^2.0.1 emoji_picker_flutter: ^1.1.2 + emoji_proposal: ^0.0.1 + emojis: ^0.9.0 encrypt: ^5.0.1 #fcm_shared_isolate: ^0.1.0 file_picker_cross: ^4.6.0 diff --git a/scripts/enable-android-google-services.patch b/scripts/enable-android-google-services.patch index 4d998b03..5b1a49ff 100644 --- a/scripts/enable-android-google-services.patch +++ b/scripts/enable-android-google-services.patch @@ -157,9 +157,9 @@ diff --git a/pubspec.yaml b/pubspec.yaml index 6999d0b8..b2c9144f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml -@@ -25,7 +25,7 @@ dependencies: - email_validator: ^2.0.1 - emoji_picker_flutter: ^1.1.2 +@@ -27,7 +27,7 @@ dependencies: + emoji_proposal: ^0.0.1 + emojis: ^0.9.0 encrypt: ^5.0.1 - #fcm_shared_isolate: ^0.1.0 + fcm_shared_isolate: ^0.1.0