diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 0c625e747..92960cde7 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2484,6 +2484,8 @@ "oldDisplayName": {} } }, + "autoplayAnimations": "Automatically play animations", + "defaultEmojiTone": "Default emoji tone", "newSpaceDescription": "Spaces allows you to consolidate your chats and build private or public communities.", "encryptThisChat": "Encrypt this chat", "endToEndEncryption": "End to end encryption", diff --git a/fonts/NotoSansSymbols-VariableFont_wght.ttf b/fonts/NotoSansSymbols-VariableFont_wght.ttf new file mode 100644 index 000000000..d8035b327 Binary files /dev/null and b/fonts/NotoSansSymbols-VariableFont_wght.ttf differ diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 75fa52a1b..e857cee4b 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -43,7 +43,7 @@ abstract class AppConfig { static bool hideUnknownEvents = true; static bool hideUnimportantStateEvents = true; static bool separateChatTypes = false; - static bool autoplayImages = true; + static bool autoplayImages = false; static bool sendTypingNotifications = true; static bool sendOnEnter = false; static bool experimentalVoip = false; @@ -60,7 +60,7 @@ abstract class AppConfig { static const String pushNotificationsGatewayUrl = 'https://push.fluffychat.im/_matrix/push/v1/notify'; static const String pushNotificationsPusherFormat = 'event_id_only'; - static const String emojiFontName = 'Noto Emoji'; + static const String emojiFontName = 'Noto Color Emoji'; static const String emojiFontUrl = 'https://github.com/googlefonts/noto-emoji/'; static const double borderRadius = 16.0; diff --git a/lib/config/themes.dart b/lib/config/themes.dart index f03c74d4f..90bba84c4 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -20,7 +20,7 @@ abstract class FluffyThemes { static const fallbackTextStyle = TextStyle( fontFamily: 'Roboto', - fontFamilyFallback: ['NotoEmoji'], + fontFamilyFallback: [AppConfig.emojiFontName], ); static var fallbackTextTheme = const TextTheme( diff --git a/lib/pages/bootstrap/bootstrap_dialog.dart b/lib/pages/bootstrap/bootstrap_dialog.dart index 8a227cb03..e751e8f55 100644 --- a/lib/pages/bootstrap/bootstrap_dialog.dart +++ b/lib/pages/bootstrap/bootstrap_dialog.dart @@ -131,7 +131,7 @@ class BootstrapDialogState extends State { minLines: 2, maxLines: 4, readOnly: true, - style: const TextStyle(fontFamily: 'RobotoMono'), + style: const TextStyle(fontFamily: 'Roboto Mono'), controller: TextEditingController(text: key), decoration: const InputDecoration( contentPadding: EdgeInsets.all(16), @@ -256,7 +256,7 @@ class BootstrapDialogState extends State { ? null : [AutofillHints.password], controller: _recoveryKeyTextEditingController, - style: const TextStyle(fontFamily: 'RobotoMono'), + style: const TextStyle(fontFamily: 'Roboto Mono'), decoration: InputDecoration( contentPadding: const EdgeInsets.all(16), hintStyle: TextStyle( diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 3ba0c4ffe..b5a8a47d5 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -187,8 +187,6 @@ class ChatController extends State { final int _loadHistoryCount = 100; - String inputText = ''; - String pendingText = ''; bool showEmojiPicker = false; @@ -295,7 +293,6 @@ class ChatController extends State { final draft = prefs.getString('draft_$roomId'); if (draft != null && draft.isNotEmpty) { sendController.text = draft; - setState(() => inputText = draft); } } @@ -472,18 +469,18 @@ class ChatController extends State { // ignore: unawaited_futures room.sendTextEvent( - sendController.text, + sendController.text.trim(), inReplyTo: replyEvent, editEventId: editEvent?.eventId, parseCommands: parseCommands, ); + // TextEditingValue required due to potential selection present sendController.value = TextEditingValue( text: pendingText, selection: const TextSelection.collapsed(offset: 0), ); setState(() { - inputText = pendingText; replyEvent = null; editEvent = null; pendingText = ''; @@ -1051,7 +1048,7 @@ class ChatController extends State { setState(() { pendingText = sendController.text; editEvent = selectedEvents.first; - inputText = sendController.text = + sendController.text = editEvent!.getDisplayEvent(timeline!).calcLocalizedBodyFallback( MatrixLocals(L10n.of(context)!), withSenderNamePrefix: false, @@ -1206,10 +1203,9 @@ class ChatController extends State { if ((prefix.isNotEmpty) && text.toLowerCase() == '${prefix.toLowerCase()} ') { setSendingClient(client); - setState(() { - inputText = ''; - sendController.text = ''; - }); + + sendController.text = ''; + return; } } @@ -1233,7 +1229,6 @@ class ChatController extends State { ); } } - setState(() => inputText = text); } bool get isArchived => @@ -1302,7 +1297,7 @@ class ChatController extends State { void cancelReplyEventAction() => setState(() { if (editEvent != null) { - inputText = sendController.text = pendingText; + sendController.text = pendingText; pendingText = ''; } replyEvent = null; diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index 6f5fe4289..8e0f59826 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -105,7 +105,7 @@ class ChatInputRow extends StatelessWidget { duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, height: 56, - width: controller.inputText.isEmpty ? 56 : 0, + width: controller.sendController.text.isEmpty ? 56 : 0, alignment: Alignment.center, clipBehavior: Clip.hardEdge, decoration: const BoxDecoration(), @@ -268,7 +268,7 @@ class ChatInputRow extends StatelessWidget { ), ), if (PlatformInfos.platformCanRecord && - controller.inputText.isEmpty) + controller.sendController.text.isEmpty) Container( height: 56, alignment: Alignment.center, @@ -278,7 +278,8 @@ class ChatInputRow extends StatelessWidget { onPressed: controller.voiceMessageAction, ), ), - if (!PlatformInfos.isMobile || controller.inputText.isNotEmpty) + if (!PlatformInfos.isMobile || + controller.sendController.text.isNotEmpty) Container( height: 56, alignment: Alignment.center, diff --git a/lib/pages/chat/events/cute_events.dart b/lib/pages/chat/events/cute_events.dart index 6a9424818..9be5f769a 100644 --- a/lib/pages/chat/events/cute_events.dart +++ b/lib/pages/chat/events/cute_events.dart @@ -1,16 +1,24 @@ import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pages/settings_chat/settings_chat.dart'; +import 'package:fluffychat/widgets/animated_emoji_plain_text.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class CuteContent extends StatefulWidget { final Event event; + final Color color; - const CuteContent(this.event, {super.key}); + const CuteContent( + this.event, { + super.key, + required this.color, + }); @override State createState() => _CuteContentState(); @@ -18,17 +26,18 @@ class CuteContent extends StatefulWidget { class _CuteContentState extends State { static bool _isOverlayShown = false; - - @override - void initState() { - if (AppConfig.autoplayImages && !_isOverlayShown) { - addOverlay(); - } - super.initState(); - } + bool initialized = false; @override Widget build(BuildContext context) { + if (initialized == false) { + initialized = true; + + if (Matrix.of(context).client.autoplayAnimatedContent ?? + !kIsWeb && !_isOverlayShown) { + addOverlay(); + } + } return FutureBuilder( future: widget.event.fetchSenderUser(), builder: (context, snapshot) { @@ -40,9 +49,10 @@ class _CuteContentState extends State { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( + TextLinkifyEmojify( widget.event.text, - style: const TextStyle(fontSize: 150), + fontSize: 150, + textColor: widget.color, ), if (label != null) Text(label), ], @@ -183,11 +193,14 @@ class _CuteOverlayContent extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox.square( - dimension: size, - child: Text( - emoji, - style: const TextStyle(fontSize: 48), + return SizedOverflowBox( + size: const Size.square(size), + child: ClipRect( + clipBehavior: Clip.hardEdge, + child: Text( + emoji, + style: const TextStyle(fontSize: 56), + ), ), ); } diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 3a47121f4..26b649732 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -1,6 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Element; import 'package:collection/collection.dart'; +import 'package:dart_animated_emoji/dart_animated_emoji.dart'; import 'package:flutter_highlighter/flutter_highlighter.dart'; import 'package:flutter_highlighter/themes/shades-of-purple.dart'; import 'package:flutter_html/flutter_html.dart'; @@ -10,6 +11,7 @@ import 'package:linkify/linkify.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/widgets/animated_emoji_plain_text.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import '../../../utils/url_launcher.dart'; @@ -18,12 +20,14 @@ class HtmlMessage extends StatelessWidget { final String html; final Room room; final Color textColor; + final bool isEmojiOnly; const HtmlMessage({ super.key, required this.html, required this.room, this.textColor = Colors.black, + this.isEmojiOnly = false, }); @override @@ -44,7 +48,9 @@ class HtmlMessage extends StatelessWidget { '', ); - final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; + double fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; + + if (isEmojiOnly) fontSize *= 3; final linkifiedRenderHtml = linkify( renderHtml, @@ -61,6 +67,16 @@ class HtmlMessage extends StatelessWidget { }, ).join(''); + final emojifiedHtml = linkifiedRenderHtml.replaceAllMapped( + RegExp( + '(${AnimatedEmoji.all.reversed.map((e) => e.fallback).join('|')})', + ), + (match) { + final emoji = linkifiedRenderHtml.substring(match.start, match.end); + return '$emoji'; + }, + ); + final linkColor = textColor.withAlpha(150); final blockquoteStyle = Style( @@ -77,7 +93,7 @@ class HtmlMessage extends StatelessWidget { return MouseRegion( cursor: SystemMouseCursors.text, child: Html( - data: linkifiedRenderHtml, + data: emojifiedHtml, style: { '*': Style( color: textColor, @@ -138,8 +154,15 @@ class HtmlMessage extends StatelessWidget { ), const TableHtmlExtension(), SpoilerExtension(textColor: textColor), - const ImageExtension(), + ImageExtension( + isEmojiOnly: isEmojiOnly, + watermarkColor: textColor, + ), FontColorExtension(), + AnimatedEmojiExtension( + isEmojiOnly: isEmojiOnly, + defaultTextColor: textColor, + ), ], onLinkTap: (url, _, element) => UrlLauncher( context, @@ -254,8 +277,14 @@ class FontColorExtension extends HtmlExtension { class ImageExtension extends HtmlExtension { final double defaultDimension; + final bool isEmojiOnly; + final Color watermarkColor; - const ImageExtension({this.defaultDimension = 64}); + const ImageExtension({ + this.defaultDimension = 64, + this.isEmojiOnly = false, + required this.watermarkColor, + }); @override Set get supportedTags => {'img'}; @@ -267,18 +296,34 @@ class ImageExtension extends HtmlExtension { return TextSpan(text: context.attributes['alt']); } - final width = double.tryParse(context.attributes['width'] ?? ''); - final height = double.tryParse(context.attributes['height'] ?? ''); + double? width, height; + // in case it's an emoji only message or a custom emoji image, + // force the default font size + if (isEmojiOnly) { + width = height = + AppConfig.messageFontSize * AppConfig.fontSizeFactor * 3 * 1.2; + } else if (context.attributes.containsKey('data-mx-emoticon') || + context.attributes.containsKey('data-mx-emoji')) { + // in case the image is a custom emote, get the surrounding font size + width = height = (tryGetParentFontSize(context) ?? + FontSize(AppConfig.messageFontSize * AppConfig.fontSizeFactor)) + .emValue; + } else { + width = double.tryParse(context.attributes['width'] ?? ''); + height = double.tryParse(context.attributes['height'] ?? ''); + } return WidgetSpan( child: SizedBox( width: width ?? height ?? defaultDimension, height: height ?? width ?? defaultDimension, child: MxcImage( + watermarkSize: (width ?? height ?? defaultDimension) / 2.5, uri: mxcUrl, width: width ?? height ?? defaultDimension, height: height ?? width ?? defaultDimension, cacheKey: mxcUrl.toString(), + watermarkColor: watermarkColor, ), ), ); @@ -330,6 +375,7 @@ class MatrixMathExtension extends HtmlExtension { final TextStyle? style; MatrixMathExtension({this.style}); + @override Set get supportedTags => {'div'}; @@ -359,10 +405,65 @@ class MatrixMathExtension extends HtmlExtension { } } +class AnimatedEmojiExtension extends HtmlExtension { + final bool isEmojiOnly; + final Color defaultTextColor; + + const AnimatedEmojiExtension({ + this.isEmojiOnly = false, + required this.defaultTextColor, + }); + + @override + Set get supportedTags => {'span'}; + + @override + bool matches(ExtensionContext context) { + if (context.elementName != 'span') return false; + final emojiData = context.element?.attributes['data-fluffy-animated-emoji']; + return emojiData != null; + } + + @override + InlineSpan build( + ExtensionContext context, + ) { + final emojiText = context.element?.innerHtml; + try { + final emoji = AnimatedEmoji.all.firstWhere( + (element) => element.fallback == emojiText, + ); + + double size; + + // in case it's an emoji only message, we can use the default emoji-only + // font size + if (isEmojiOnly) { + size = AppConfig.messageFontSize * AppConfig.fontSizeFactor * 3 * 1.125; + } else { + // otherwise try to gather the parenting element's font size. + final fontSize = (tryGetParentFontSize(context) ?? + FontSize(AppConfig.messageFontSize * AppConfig.fontSizeFactor)); + size = fontSize.emValue * 1.125; + } + return WidgetSpan( + child: AnimatedEmojiLottieView( + emoji: emoji, + size: size, + textColor: defaultTextColor, + ), + ); + } catch (_) { + return TextSpan(text: emojiText); + } + } +} + class CodeExtension extends HtmlExtension { final double fontSize; CodeExtension({required this.fontSize}); + @override Set get supportedTags => {'code'}; @@ -400,6 +501,7 @@ class RoomPillExtension extends HtmlExtension { final BuildContext context; RoomPillExtension(this.context, this.room); + @override Set get supportedTags => {'a'}; @@ -511,3 +613,15 @@ class MatrixPill extends StatelessWidget { ); } } + +FontSize? tryGetParentFontSize(ExtensionContext context) { + var currentElement = context.element; + while (currentElement?.parent != null) { + currentElement = currentElement?.parent; + final size = context.parser.style[(currentElement!.localName!)]?.fontSize; + if (size != null) { + return size; + } + } + return null; +} diff --git a/lib/pages/chat/events/image_bubble.dart b/lib/pages/chat/events/image_bubble.dart index ab5fb03cd..61d6d7ff4 100644 --- a/lib/pages/chat/events/image_bubble.dart +++ b/lib/pages/chat/events/image_bubble.dart @@ -19,6 +19,7 @@ class ImageBubble extends StatelessWidget { final double height; final void Function()? onTap; final BorderRadius? borderRadius; + final Color? watermarkColor; const ImageBubble( this.event, { @@ -30,6 +31,7 @@ class ImageBubble extends StatelessWidget { this.width = 400, this.height = 300, this.animated = false, + this.watermarkColor, this.onTap, this.borderRadius, super.key, @@ -102,13 +104,17 @@ class ImageBubble extends StatelessWidget { ) : const BoxConstraints.expand(), child: MxcImage( + key: ValueKey(event.eventId), event: event, width: width, height: height, fit: fit, animated: animated, + disableTapHandler: true, isThumbnail: thumbnailOnly, placeholder: _buildPlaceholder, + watermarkSize: width / 2.5, + watermarkColor: watermarkColor, ), ), ), diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 0e90ef9b0..f178efe5d 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:emoji_regex/emoji_regex.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/chat/events/video_player.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/animated_emoji_plain_text.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../../config/app_config.dart'; @@ -115,12 +116,16 @@ class MessageContent extends StatelessWidget { height: 300, fit: BoxFit.cover, borderRadius: borderRadius, + watermarkColor: textColor, ); case MessageTypes.Sticker: if (event.redacted) continue textmessage; - return Sticker(event); + return Sticker( + event, + watermarkColor: textColor, + ); case CuteEventContent.eventType: - return CuteContent(event); + return CuteContent(event, color: textColor); case MessageTypes.Audio: if (PlatformInfos.isMobile || PlatformInfos.isMacOS || @@ -157,6 +162,7 @@ class MessageContent extends StatelessWidget { html: html, textColor: textColor, room: event.room, + isEmojiOnly: event.onlyEmotes, ); } // else we fall through to the normal message rendering @@ -232,7 +238,12 @@ class MessageContent extends StatelessWidget { }, ); } - final bigEmotes = event.onlyEmotes && + final bigEmotes = (event.onlyEmotes || + emojiRegex() + .allMatches(event.text) + .map((e) => e[0]) + .join() == + event.text) && event.numberEmotes > 0 && event.numberEmotes <= 10; return FutureBuilder( @@ -241,26 +252,17 @@ class MessageContent extends StatelessWidget { hideReply: true, ), builder: (context, snapshot) { - return Linkify( - text: snapshot.data ?? - event.calcLocalizedBodyFallback( - MatrixLocals(L10n.of(context)!), - hideReply: true, - ), - style: TextStyle( - color: textColor, - fontSize: bigEmotes ? fontSize * 3 : fontSize, - decoration: - event.redacted ? TextDecoration.lineThrough : null, - ), - options: const LinkifyOptions(humanize: false), - linkStyle: TextStyle( - color: textColor.withAlpha(150), - fontSize: bigEmotes ? fontSize * 3 : fontSize, - decoration: TextDecoration.underline, - decorationColor: textColor.withAlpha(150), - ), - onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + final text = snapshot.data ?? + event.calcLocalizedBodyFallback( + MatrixLocals(L10n.of(context)!), + hideReply: true, + ); + return TextLinkifyEmojify( + text, + fontSize: bigEmotes ? fontSize * 3 : fontSize, + textDecoration: + event.redacted ? TextDecoration.lineThrough : null, + textColor: textColor, ); }, ); diff --git a/lib/pages/chat/events/message_reactions.dart b/lib/pages/chat/events/message_reactions.dart index 7e731c93b..8ccd0f20a 100644 --- a/lib/pages/chat/events/message_reactions.dart +++ b/lib/pages/chat/events/message_reactions.dart @@ -5,6 +5,7 @@ import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/widgets/animated_emoji_plain_text.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; @@ -110,7 +111,7 @@ class _Reaction extends StatelessWidget { ? Colors.white : Colors.black; final color = Theme.of(context).scaffoldBackgroundColor; - final fontSize = DefaultTextStyle.of(context).style.fontSize; + final fontSize = DefaultTextStyle.of(context).style.fontSize ?? 12; Widget content; if (reactionKey!.startsWith('mxc://')) { content = Row( @@ -120,6 +121,8 @@ class _Reaction extends StatelessWidget { uri: Uri.parse(reactionKey!), width: 9999, height: fontSize, + watermarkColor: color, + watermarkSize: fontSize / 1.5, ), const SizedBox(width: 4), Text( @@ -136,12 +139,10 @@ class _Reaction extends StatelessWidget { if (renderKey.length > 10) { renderKey = renderKey.getRange(0, 9) + Characters('…'); } - content = Text( + content = TextLinkifyEmojify( '$renderKey $count', - style: TextStyle( - color: textColor, - fontSize: DefaultTextStyle.of(context).style.fontSize, - ), + textColor: textColor, + fontSize: fontSize, ); } return InkWell( @@ -218,7 +219,12 @@ class _AdaptableReactorsDialog extends StatelessWidget { ), ); - final title = Center(child: Text(reactionEntry!.key!)); + final title = Center( + child: TextLinkifyEmojify( + reactionEntry!.key!, + fontSize: Theme.of(context).textTheme.headlineLarge?.fontSize ?? 24, + ), + ); return AlertDialog.adaptive( title: title, diff --git a/lib/pages/chat/events/sticker.dart b/lib/pages/chat/events/sticker.dart index 8e52bfe39..a7d2c57a5 100644 --- a/lib/pages/chat/events/sticker.dart +++ b/lib/pages/chat/events/sticker.dart @@ -4,13 +4,14 @@ import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; -import '../../../config/app_config.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'image_bubble.dart'; class Sticker extends StatefulWidget { final Event event; + final Color watermarkColor; - const Sticker(this.event, {super.key}); + const Sticker(this.event, {super.key, required this.watermarkColor}); @override StickerState createState() => StickerState(); diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index c179227a7..85a13ca26 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -9,6 +9,7 @@ import 'package:pasteboard/pasteboard.dart'; import 'package:slugify/slugify.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pages/settings_emotes/settings_emotes.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import '../../widgets/avatar.dart'; @@ -47,7 +48,10 @@ class InputBar extends StatelessWidget { super.key, }); - List> getSuggestions(String text) { + List> getSuggestions( + String text, { + fitzpatrick tone = fitzpatrick.None, + }) { if (controller!.selection.baseOffset != controller!.selection.extentOffset || controller!.selection.baseOffset < 0) { @@ -119,12 +123,28 @@ 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(); + + final matchingUnicodeEmojis = List.from( + Emoji.all() + // filter out duplicate skins in order to reduce the list length + .where( + (element) => [ + element.name, + ...element.keywords, + ].any( + (element) => element.toLowerCase().contains(emoteSearch), + ), + ) + // shorten the list by reducing redundant skin tones + .map((e) { + try { + // TODO: find a way to filter out different hair colors + return e.newSkin(tone); + } catch (_) { + return e; + } + }).toSet(), + ); // 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) @@ -397,6 +417,7 @@ class InputBar extends StatelessWidget { final useShortCuts = (PlatformInfos.isWeb || PlatformInfos.isDesktop || AppConfig.sendOnEnter); + final tone = Matrix.of(context).client.defaultEmojiTone; return Shortcuts( shortcuts: !useShortCuts ? {} @@ -473,7 +494,7 @@ class InputBar extends StatelessWidget { }, textCapitalization: TextCapitalization.sentences, ), - suggestionsCallback: getSuggestions, + suggestionsCallback: (q) => getSuggestions(q, tone: tone), itemBuilder: (c, s) => buildSuggestion(c, s, Matrix.of(context).client), onSuggestionSelected: (Map suggestion) => diff --git a/lib/pages/chat/pinned_events.dart b/lib/pages/chat/pinned_events.dart index e7c90c6e1..404e996fb 100644 --- a/lib/pages/chat/pinned_events.dart +++ b/lib/pages/chat/pinned_events.dart @@ -4,13 +4,12 @@ import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; -import 'package:fluffychat/utils/url_launcher.dart'; +import 'package:fluffychat/widgets/animated_emoji_plain_text.dart'; class PinnedEvents extends StatelessWidget { final ChatController controller; @@ -107,34 +106,20 @@ class PinnedEvents extends StatelessWidget { hideReply: true, ), builder: (context, snapshot) { - return Linkify( - text: snapshot.data ?? - event.calcLocalizedBodyFallback( - MatrixLocals(L10n.of(context)!), - withSenderNamePrefix: true, - hideReply: true, - ), - options: const LinkifyOptions(humanize: false), - maxLines: 2, - style: TextStyle( - color: - Theme.of(context).colorScheme.onSurfaceVariant, - overflow: TextOverflow.ellipsis, - fontSize: fontSize, - decoration: event.redacted - ? TextDecoration.lineThrough - : null, - ), - linkStyle: TextStyle( - color: - Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: fontSize, - decoration: TextDecoration.underline, - decorationColor: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), + final text = snapshot.data ?? + event.calcLocalizedBodyFallback( + MatrixLocals(L10n.of(context)!), + withSenderNamePrefix: true, + hideReply: true, + ); + return TextLinkifyEmojify( + text, + fontSize: fontSize, + textColor: + Theme.of(context).colorScheme.onSurfaceVariant, + textDecoration: event.redacted + ? TextDecoration.lineThrough + : null, ); }, ), diff --git a/lib/pages/chat_details/chat_details_view.dart b/lib/pages/chat_details/chat_details_view.dart index 1dc923666..ceab662b0 100644 --- a/lib/pages/chat_details/chat_details_view.dart +++ b/lib/pages/chat_details/chat_details_view.dart @@ -10,11 +10,11 @@ import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_details/participant_list_item.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/chat_settings_popup_menu.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import '../../utils/url_launcher.dart'; class ChatDetailsView extends StatelessWidget { final ChatDetailsController controller; diff --git a/lib/pages/image_viewer/image_viewer_view.dart b/lib/pages/image_viewer/image_viewer_view.dart index 7646c8a60..117a1df41 100644 --- a/lib/pages/image_viewer/image_viewer_view.dart +++ b/lib/pages/image_viewer/image_viewer_view.dart @@ -62,7 +62,7 @@ class ImageViewerView extends StatelessWidget { event: controller.widget.event, fit: BoxFit.contain, isThumbnail: false, - animated: true, + forceAnimation: true, ), ), ), diff --git a/lib/pages/settings_chat/settings_chat.dart b/lib/pages/settings_chat/settings_chat.dart index 1c1035559..c383262ed 100644 --- a/lib/pages/settings_chat/settings_chat.dart +++ b/lib/pages/settings_chat/settings_chat.dart @@ -1,5 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/widgets/matrix.dart'; import 'settings_chat_view.dart'; class SettingsChat extends StatefulWidget { @@ -12,4 +16,47 @@ class SettingsChat extends StatefulWidget { class SettingsChatController extends State { @override Widget build(BuildContext context) => SettingsChatView(this); + + bool get autoplayAnimations => + Matrix.of(context).client.autoplayAnimatedContent ?? !kIsWeb; + + Future setAutoplayAnimations(bool value) async { + try { + final client = Matrix.of(context).client; + await client.setAutoplayAnimatedContent(value); + } catch (e) { + Logs().w('Error storing animation preferences.', e); + } finally { + setState(() {}); + } + } +} + +extension AutoplayAnimatedContentExtension on Client { + static const _elementWebKey = 'im.vector.web.settings'; + + /// returns whether user preferences configured to autoplay motion + /// message content such as gifs, webp, apng, videos or animations. + bool? get autoplayAnimatedContent { + if (!accountData.containsKey(_elementWebKey)) return null; + try { + final elementWebData = accountData[_elementWebKey]?.content; + return elementWebData?['autoplayGifs'] as bool?; + } catch (e) { + return null; + } + } + + Future setAutoplayAnimatedContent(bool autoplay) async { + final elementWebData = accountData[_elementWebKey]?.content ?? {}; + elementWebData['autoplayGifs'] = autoplay; + final uid = userID; + if (uid != null) { + await setAccountData( + uid, + _elementWebKey, + elementWebData, + ); + } + } } diff --git a/lib/pages/settings_chat/settings_chat_view.dart b/lib/pages/settings_chat/settings_chat_view.dart index 1d0f6686b..8ce1aecf3 100644 --- a/lib/pages/settings_chat/settings_chat_view.dart +++ b/lib/pages/settings_chat/settings_chat_view.dart @@ -5,11 +5,11 @@ import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/voip/callkeep_manager.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/settings_switch_list_tile.dart'; +import '../../utils/platform_infos.dart'; import 'settings_chat.dart'; class SettingsChatView extends StatelessWidget { @@ -56,13 +56,11 @@ class SettingsChatView extends StatelessWidget { storeKey: SettingKeys.hideUnimportantStateEvents, defaultValue: AppConfig.hideUnimportantStateEvents, ), - if (PlatformInfos.isMobile) - SettingsSwitchListTile.adaptive( - title: L10n.of(context)!.autoplayImages, - onChanged: (b) => AppConfig.autoplayImages = b, - storeKey: SettingKeys.autoplayImages, - defaultValue: AppConfig.autoplayImages, - ), + SwitchListTile.adaptive( + title: Text(L10n.of(context)!.autoplayAnimations), + value: controller.autoplayAnimations, + onChanged: controller.setAutoplayAnimations, + ), const Divider(), SettingsSwitchListTile.adaptive( title: L10n.of(context)!.sendTypingNotifications, diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 2ef905b17..8330c7c16 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:collection/collection.dart'; +import 'package:emojis/emoji.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; @@ -273,6 +274,19 @@ class EmotesSettingsController extends State { } } + fitzpatrick get defaultTone => Matrix.of(context).client.defaultEmojiTone; + + Future setDefaultTone(fitzpatrick value) async { + try { + final client = Matrix.of(context).client; + await client.setDefaultEmojiTone(value); + } catch (e) { + Logs().w('Error storing animation preferences.', e); + } finally { + setState(() {}); + } + } + @override Widget build(BuildContext context) { return EmotesSettingsView(this); @@ -355,3 +369,47 @@ class EmotesSettingsController extends State { ); } } + +extension DefaultEmojiTone on Client { + static const _emoteConfigKey = 'im.fluffychat.emote_config'; + + /// returns whether user preferences configured to autoplay motion + /// message content such as gifs, webp, apng, videos or animations. + fitzpatrick get defaultEmojiTone { + if (!accountData.containsKey(_emoteConfigKey)) return fitzpatrick.None; + try { + final elementWebData = accountData[_emoteConfigKey]?.content; + final encoded = elementWebData?['tone'] as String?; + switch (encoded) { + case 'light': + return fitzpatrick.light; + case 'mediumLight': + return fitzpatrick.mediumLight; + case 'medium': + return fitzpatrick.medium; + case 'mediumDark': + return fitzpatrick.mediumDark; + case 'dark': + return fitzpatrick.dark; + default: + return fitzpatrick.None; + } + } catch (e) { + return fitzpatrick.None; + } + } + + Future setDefaultEmojiTone(fitzpatrick tone) async { + final elementWebData = accountData[_emoteConfigKey]?.content ?? {}; + final name = tone == fitzpatrick.None ? null : tone.name; + elementWebData['tone'] = name; + final uid = userID; + if (uid != null) { + await setAccountData( + uid, + _emoteConfigKey, + elementWebData, + ); + } + } +} diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index acb93788e..84d55aa7e 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:dart_animated_emoji/dart_animated_emoji.dart'; +import 'package:emojis/emoji.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:lottie/lottie.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/animated_emoji_plain_text.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import '../../widgets/matrix.dart'; @@ -12,6 +16,8 @@ import 'settings_emotes.dart'; enum PopupMenuEmojiActions { import, export } +const colorPickerSize = 32.0; + class EmotesSettingsView extends StatelessWidget { final EmotesSettingsController controller; @@ -61,6 +67,64 @@ class EmotesSettingsView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (controller.room == null) ...[ + ListTile( + title: Text(L10n.of(context)!.defaultEmojiTone), + ), + SizedBox( + height: colorPickerSize + 24, + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: fitzpatrick.values + .map( + (tone) => Padding( + padding: const EdgeInsets.all(12.0), + child: InkWell( + borderRadius: + BorderRadius.circular(colorPickerSize), + onTap: () => controller.setDefaultTone(tone), + child: Material( + elevation: 6, + borderRadius: + BorderRadius.circular(colorPickerSize), + child: SizedBox( + width: colorPickerSize, + height: colorPickerSize, + child: controller.defaultTone == tone + ? Center( + child: Lottie.memory( + Uint8List.fromList( + AnimatedEmoji.all + .firstWhere( + (e) => + e.fallback == + Emoji.modify( + '\u{1f44b}', + tone, + ), + ) + .lottieAnimation + .codeUnits, + ), + ), + ) + : Center( + child: Text( + Emoji.modify('\u{1f44b}', tone), + style: const TextStyle(fontSize: 24), + ), + ), + ), + ), + ), + ), + ) + .toList(), + ), + ), + const Divider(), + ], if (!controller.readonly) Container( padding: const EdgeInsets.symmetric( @@ -122,9 +186,9 @@ class EmotesSettingsView extends StatelessWidget { ? Center( child: Padding( padding: const EdgeInsets.all(16), - child: Text( + child: TextLinkifyEmojify( L10n.of(context)!.noEmotesFound, - style: const TextStyle(fontSize: 20), + fontSize: 20, ), ), ) @@ -247,6 +311,7 @@ class _EmoteImage extends StatelessWidget { fit: BoxFit.contain, width: size, height: size, + forceAnimation: true, ), ); } diff --git a/lib/widgets/animated_emoji_plain_text.dart b/lib/widgets/animated_emoji_plain_text.dart new file mode 100644 index 000000000..27944d6ba --- /dev/null +++ b/lib/widgets/animated_emoji_plain_text.dart @@ -0,0 +1,217 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:dart_animated_emoji/dart_animated_emoji.dart'; +import 'package:emoji_regex/emoji_regex.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:lottie/lottie.dart'; + +import 'package:fluffychat/pages/settings_chat/settings_chat.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// takes a text as input and parses out Animated Emojis adn Linkifys it +class TextLinkifyEmojify extends StatelessWidget { + final String text; + final double fontSize; + final Color? textColor; + final TextDecoration? textDecoration; + + const TextLinkifyEmojify( + this.text, { + super.key, + required this.fontSize, + this.textColor, + this.textDecoration, + }); + + @override + Widget build(BuildContext context) { + String text = this.text; + final regex = emojiRegex(); + + final animate = + Matrix.of(context).client.autoplayAnimatedContent ?? !kIsWeb; + + final parts = []; + do { + // in order to prevent animated rendering of partial emojis in case + // the glyph is constructed from several code points, match on emojis in + // general and then check whether the entire glyph is animatable + final match = regex.allMatches(text).firstWhereOrNull( + (match) => + AnimatedEmoji.all.any((emoji) => emoji.fallback == match[0]), + ); + + if (match == null || match.start != 0) { + parts.add(_linkifyString(text.substring(0, match?.start), context)); + } + if (match != null) { + final emoji = AnimatedEmoji.all.firstWhere( + (element) => element.fallback == match[0], + ); + parts.add(_lottieBox(emoji, animate)); + text = text.substring(match.end); + } else { + text = ''; + } + } while (regex.hasMatch(text)); + if (text.isNotEmpty) { + parts.add(_linkifyString(text, context)); + } + if (parts.length == 1) { + return parts.single; + } else { + return Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 2, + runSpacing: 2, + children: parts, + ); + } + } + + Widget _linkifyString(String text, BuildContext context) { + return Linkify( + text: text, + style: TextStyle( + color: textColor, + fontSize: fontSize, + decoration: textDecoration, + ), + options: const LinkifyOptions(humanize: false), + linkStyle: TextStyle( + color: textColor?.withAlpha(150), + fontSize: fontSize, + decoration: TextDecoration.underline, + decorationColor: textColor?.withAlpha(150), + ), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + ); + } + + Widget _lottieBox(AnimatedEmoji emoji, bool animate) { + return AnimatedEmojiLottieView( + emoji: emoji, + size: fontSize * 1.25, + textColor: textColor, + ); + } +} + +class AnimatedEmojiLottieView extends StatelessWidget { + final AnimatedEmoji emoji; + final double size; + final Color? textColor; + + const AnimatedEmojiLottieView({ + super.key, + required this.emoji, + required this.size, + this.textColor, + }); + + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: size, + child: AnimationEnabledContainerView( + iconSize: size / 2.5, + builder: (animate) { + return Lottie.memory( + key: ValueKey(emoji.name + size.toString()), + Uint8List.fromList(emoji.lottieAnimation.codeUnits), + animate: animate, + ); + }, + textColor: textColor, + ), + ); +} + +typedef AnimatedChildBuilder = Widget Function(bool animate); + +class AnimationEnabledContainerView extends StatefulWidget { + final AnimatedChildBuilder builder; + final double iconSize; + final Color? textColor; + final bool disableTapHandler; + + const AnimationEnabledContainerView({ + super.key, + required this.builder, + required this.iconSize, + this.textColor, + this.disableTapHandler = false, + }); + + @override + State createState() => + _AnimationEnabledContainerViewState(); +} + +class _AnimationEnabledContainerViewState + extends State { + bool get autoplay => + Matrix.of(context).client.autoplayAnimatedContent ?? true; + + /// whether to animate though autoplay disabled + bool animating = false; + + @override + Widget build(BuildContext context) { + final autoplay = this.autoplay; + + final box = widget.builder.call(autoplay || animating); + + if (autoplay) return box; + + return MouseRegion( + onEnter: startAnimation, + onHover: startAnimation, + onExit: stopAnimation, + child: GestureDetector( + onTap: widget.disableTapHandler ? null : toggleAnimation, + child: Stack( + alignment: Alignment.bottomRight, + fit: StackFit.loose, + children: [ + box, + if (!animating) + Icon( + Icons.gif, + size: widget.iconSize, + color: widget.textColor, + ), + ], + ), + ), + ); + } + + void startAnimation(PointerEvent e) { + if (e.kind == PointerDeviceKind.mouse) { + setState(() => animating = true); + } + } + + void stopAnimation(PointerEvent e) { + if (e.kind == PointerDeviceKind.mouse) { + setState(() => animating = false); + } + } + + void toggleAnimation() => setState(() => animating = !animating); + + @override + void didUpdateWidget(covariant AnimationEnabledContainerView oldWidget) { + if (oldWidget.builder != widget.builder || + oldWidget.iconSize != widget.iconSize || + oldWidget.textColor != widget.textColor) { + setState(() {}); + } + super.didUpdateWidget(oldWidget); + } +} diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index 0496c52ae..42df1d86a 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -73,6 +73,8 @@ class Avatar extends StatelessWidget { width: size, height: size, placeholder: (_) => textWidget, + watermarkSize: fontSize, + watermarkColor: Colors.white, cacheKey: mxContent.toString(), ), ), diff --git a/lib/widgets/content_banner.dart b/lib/widgets/content_banner.dart index f9e6a6167..8afad7960 100644 --- a/lib/widgets/content_banner.dart +++ b/lib/widgets/content_banner.dart @@ -54,7 +54,6 @@ class ContentBanner extends StatelessWidget { : MxcImage( key: Key(mxContent?.toString() ?? 'NoKey'), uri: mxContent, - animated: true, fit: BoxFit.cover, placeholder: placeholder, height: 400, diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index ec529db16..d03ea991b 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -19,6 +19,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:universal_html/html.dart' as html; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:fluffychat/pages/settings_chat/settings_chat.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -62,6 +63,7 @@ class Matrix extends StatefulWidget { class MatrixState extends State with WidgetsBindingObserver { int _activeClient = -1; String? activeBundle; + SharedPreferences get store => widget.store; HomeserverSummary? loginHomeserverSummary; @@ -446,8 +448,18 @@ class MatrixState extends State with WidgetsBindingObserver { store.getBool(SettingKeys.separateChatTypes) ?? AppConfig.separateChatTypes; - AppConfig.autoplayImages = - store.getBool(SettingKeys.autoplayImages) ?? AppConfig.autoplayImages; + AppConfig.autoplayImages = store.getBool(SettingKeys.autoplayImages) ?? + client.autoplayAnimatedContent ?? + AppConfig.autoplayImages; + + // migrating stored autoplay preferences to account data + if (AppConfig.autoplayImages != client.autoplayAnimatedContent) { + unawaited( + client + .setAutoplayAnimatedContent(AppConfig.autoplayImages) + .then((value) => store.remove(SettingKeys.autoplayImages)), + ); + } AppConfig.sendTypingNotifications = store.getBool(SettingKeys.sendTypingNotifications) ?? diff --git a/lib/widgets/mxc_image.dart b/lib/widgets/mxc_image.dart index 0aedd1e4f..9a1149289 100644 --- a/lib/widgets/mxc_image.dart +++ b/lib/widgets/mxc_image.dart @@ -1,19 +1,30 @@ -import 'dart:typed_data'; +import 'dart:async'; +import 'dart:ui' as ui show Image; +import 'dart:ui'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/settings_chat/settings_chat.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'animated_emoji_plain_text.dart'; + +enum AnimationState { userDefined, forced, disabled } class MxcImage extends StatefulWidget { final Uri? uri; final Event? event; final double? width; final double? height; + final double? watermarkSize; + final Color? watermarkColor; + final bool forceAnimation; + final bool disableTapHandler; final BoxFit? fit; final bool isThumbnail; final bool animated; @@ -38,6 +49,10 @@ class MxcImage extends StatefulWidget { this.animationCurve = FluffyThemes.animationCurve, this.thumbnailMethod = ThumbnailMethod.scale, this.cacheKey, + this.watermarkSize, + this.watermarkColor, + this.forceAnimation = false, + this.disableTapHandler = false, super.key, }); @@ -46,14 +61,42 @@ class MxcImage extends StatefulWidget { } class _MxcImageState extends State { - static final Map _imageDataCache = {}; - Uint8List? _imageDataNoCache; - Uint8List? get _imageData { + static final Map _imageDataCache = {}; + ImageFutureResponse? _imageDataNoCache; + + ImageFutureResponse? get _imageData { final cacheKey = widget.cacheKey; return cacheKey == null ? _imageDataNoCache : _imageDataCache[cacheKey]; } - set _imageData(Uint8List? data) { + /// asynchronously + Future removeImageAnimations(Uint8List data) async { + final provider = MemoryImage(data); + + final codec = await instantiateImageCodecWithSize( + await ImmutableBuffer.fromUint8List(data), + ); + if (codec.frameCount > 1) { + final frame = await codec.getNextFrame(); + return ThumbnailImageResponse( + thumbnail: frame.image, + imageProvider: provider, + ); + } else { + return ImageProviderFutureResponse(provider); + } + } + + Future _renderImageFrame(Uint8List data) async { + if (widget.forceAnimation || + (Matrix.of(context).client.autoplayAnimatedContent ?? !kIsWeb)) { + return ImageProviderFutureResponse(MemoryImage(data)); + } else { + return await removeImageAnimations(data); + } + } + + set _imageData(ImageFutureResponse? data) { if (data == null) return; final cacheKey = widget.cacheKey; cacheKey == null @@ -90,9 +133,9 @@ class _MxcImageState extends State { if (_isCached == null) { final cachedData = await client.database?.getFile(storeKey); if (cachedData != null) { + _imageData = await _renderImageFrame(cachedData); if (!mounted) return; setState(() { - _imageData = cachedData; _isCached = true; }); return; @@ -109,10 +152,9 @@ class _MxcImageState extends State { } final remoteData = response.bodyBytes; + _imageData = await _renderImageFrame(remoteData); if (!mounted) return; - setState(() { - _imageData = remoteData; - }); + setState(() {}); await client.database?.storeFile(storeKey, remoteData, 0); } @@ -121,10 +163,9 @@ class _MxcImageState extends State { getThumbnail: widget.isThumbnail, ); if (data.detectFileType is MatrixImageFile) { + _imageData = await _renderImageFrame(data.bytes); if (!mounted) return; - setState(() { - _imageData = data.bytes; - }); + setState(() {}); return; } } @@ -157,26 +198,75 @@ class _MxcImageState extends State { Widget build(BuildContext context) { final data = _imageData; + Widget child; + if (data is ThumbnailImageResponse) { + child = AnimationEnabledContainerView( + builder: (bool animate) => animate + ? _buildImageProvider(data.imageProvider) + : _buildFrameImage(data.thumbnail), + disableTapHandler: widget.disableTapHandler, + iconSize: widget.watermarkSize ?? 0, + textColor: widget.watermarkColor ?? Colors.transparent, + ); + } else if (data is ImageProviderFutureResponse) { + child = _buildImageProvider(data.imageProvider); + } else { + child = const SizedBox.shrink(); + } + return AnimatedCrossFade( duration: widget.animationDuration, crossFadeState: data == null ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: placeholder(context), - secondChild: data == null || data.isEmpty - ? const SizedBox.shrink() - : Image.memory( - data, - width: widget.width, - height: widget.height, - fit: widget.fit, - filterQuality: FilterQuality.medium, - errorBuilder: (context, __, ___) { - _isCached = false; - _imageData = null; - WidgetsBinding.instance.addPostFrameCallback(_tryLoad); - return placeholder(context); - }, - ), + secondChild: child, + ); + } + + Widget _buildFrameImage(ui.Image image) { + return RawImage( + key: ValueKey(image), + image: image, + width: widget.width, + height: widget.height, + fit: widget.fit, + filterQuality: FilterQuality.medium, + ); + } + + Widget _buildImageProvider(ImageProvider image) { + return Image( + key: ValueKey(image), + image: image, + width: widget.width, + height: widget.height, + fit: widget.fit, + filterQuality: FilterQuality.medium, + errorBuilder: (context, __, ___) { + _isCached = false; + _imageData = null; + WidgetsBinding.instance.addPostFrameCallback(_tryLoad); + return placeholder(context); + }, ); } } + +abstract class ImageFutureResponse { + const ImageFutureResponse(); +} + +class ImageProviderFutureResponse extends ImageFutureResponse { + final ImageProvider imageProvider; + + const ImageProviderFutureResponse(this.imageProvider); +} + +class ThumbnailImageResponse extends ImageProviderFutureResponse { + final ui.Image thumbnail; + + const ThumbnailImageResponse({ + required this.thumbnail, + required ImageProvider imageProvider, + }) : super(imageProvider); +} diff --git a/lib/widgets/public_room_bottom_sheet.dart b/lib/widgets/public_room_bottom_sheet.dart index cfe21c5e3..4998569e5 100644 --- a/lib/widgets/public_room_bottom_sheet.dart +++ b/lib/widgets/public_room_bottom_sheet.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/utils/url_launcher.dart'; +import 'package:fluffychat/widgets/animated_emoji_plain_text.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../utils/localized_exception_extension.dart'; @@ -152,16 +151,10 @@ class PublicRoomBottomSheet extends StatelessWidget { color: Theme.of(context).colorScheme.secondary, ), ), - subtitle: Linkify( - text: profile!.topic!, - linkStyle: const TextStyle(color: Colors.blueAccent), - style: TextStyle( - fontSize: 14, - color: Theme.of(context).textTheme.bodyMedium!.color, - ), - options: const LinkifyOptions(humanize: false), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), + subtitle: TextLinkifyEmojify( + profile!.topic!, + fontSize: 14, + textColor: Theme.of(context).textTheme.bodyMedium!.color!, ), ), ], diff --git a/pubspec.lock b/pubspec.lock index 8b2b21cb2..dca2adab2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -249,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" + dart_animated_emoji: + dependency: "direct main" + description: + name: dart_animated_emoji + sha256: bede7cf617a42b77376ed426d68fc7aebc09672cbc3021298bafd36445a3678c + url: "https://pub.dev" + source: hosted + version: "0.0.3" dart_code_metrics: dependency: "direct dev" description: @@ -361,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.1" + emoji_regex: + dependency: "direct main" + description: + name: emoji_regex + sha256: "3a25dd4d16f98b6f76dc37cc9ae49b8511891ac4b87beac9443a1e9f4634b6c7" + url: "https://pub.dev" + source: hosted + version: "0.0.5" emojis: dependency: "direct main" description: @@ -1117,6 +1133,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: f461105d3a35887b27089abf9c292334478dd292f7b47ecdccb6ae5c37a22c80 + url: "https://pub.dev" + source: hosted + version: "2.4.0" macos_ui: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 64017f11b..503fe1013 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: chewie: ^1.7.1 collection: ^1.17.2 cupertino_icons: any + dart_animated_emoji: ^0.0.1 desktop_drop: ^0.4.4 desktop_lifecycle: ^0.1.0 desktop_notifications: ^0.6.3 @@ -25,6 +26,7 @@ dependencies: dynamic_color: ^1.6.8 emoji_picker_flutter: ^1.6.3 emoji_proposal: ^0.0.1 + emoji_regex: ^0.0.5 emojis: ^0.9.9 #fcm_shared_isolate: ^0.1.0 file_picker: ^6.1.1 @@ -62,6 +64,7 @@ dependencies: keyboard_shortcuts: ^0.1.4 latlong2: ^0.8.1 linkify: ^5.0.0 + lottie: ^2.4.0 matrix: ^0.22.6 matrix_homeserver_recommendations: ^0.3.0 native_imaging: ^0.1.0 @@ -118,6 +121,10 @@ flutter: - assets/js/package/ fonts: + # The roboto font must be named exactly this way. + # + # Issue : https://github.com/flutter/flutter/issues/77580#issuecomment-1112333700 + # Source : https://github.com/flutter/engine/blob/3.10.2/lib/web_ui/lib/src/engine/canvaskit/fonts.dart#L133 - family: Roboto fonts: - asset: fonts/Roboto/Roboto-Regular.ttf @@ -125,12 +132,22 @@ flutter: style: italic - asset: fonts/Roboto/Roboto-Bold.ttf weight: 700 - - family: RobotoMono + - family: Roboto Mono fonts: - asset: fonts/Roboto/RobotoMono-Regular.ttf - - family: NotoEmoji + # These three Noto font families are hardcoded in the Flutter engine to be loaded as fallback + # from Google Fonts in case characters are supposed to be displayed that are not available in + # the provided fonts. + # + # The fonts may NOT be renamed in their family name we use in Dart. + # + # Source : https://github.com/flutter/engine/blob/3.10.2/lib/web_ui/lib/src/engine/canvaskit/font_fallback_data.dart + - family: Noto Color Emoji fonts: - asset: fonts/NotoEmoji/NotoColorEmoji.ttf + - family: Noto Sans Symbols + fonts: + - asset: fonts/NotoSansSymbols-VariableFont_wght.ttf msix_config: display_name: FluffyChat diff --git a/scripts/enable-android-google-services.patch b/scripts/enable-android-google-services.patch index 0ebe5f89c..3efcd8ee0 100644 --- a/scripts/enable-android-google-services.patch +++ b/scripts/enable-android-google-services.patch @@ -148,9 +148,9 @@ diff --git a/pubspec.yaml b/pubspec.yaml index 6999d0b8..b2c9144f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml -@@ -26,7 +26,7 @@ dependencies: - emoji_picker_flutter: ^1.6.3 +@@ -27,7 +27,7 @@ dependencies: emoji_proposal: ^0.0.1 + emoji_regex: ^0.0.5 emojis: ^0.9.9 - #fcm_shared_isolate: ^0.1.0 + fcm_shared_isolate: ^0.1.0