feat: Emoji related fixes

- adds Emoji autocomplete following popular `:` hotkey
- adds Famedly's famous smart Emojis (tm)
- syncs recent Emojis with SDK

Signed-off-by: TheOneWithTheBraid <the-one@with-the-braid.cf>
This commit is contained in:
TheOneWithTheBraid 2022-07-14 16:04:24 +02:00 committed by The one with the Braid
parent 3e3858d729
commit 3e80e3f67e
7 changed files with 500 additions and 12 deletions

View File

@ -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,
);

View File

@ -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<Map<String, String?>> ret = <Map<String, String?>>[];
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<String, String?> 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
),

View File

@ -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<String>.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<String>.from(AppEmojis.emojis);
final allReactionEvents = controller.selectedEvents.first
.aggregatedEvents(
controller.timeline!, RelationshipTypes.reaction)

View File

@ -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<FluffyEmojiPickerView>
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<Widget>((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;
}
}

View File

@ -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:

View File

@ -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

View File

@ -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