Skip to content

Commit

Permalink
feat: add animated emoji support
Browse files Browse the repository at this point in the history
- implement animated emoji support in both HTML and Linkify message type
- fix some missing font glyphs
- trim message input

Signed-off-by: The one with the braid <[email protected]>
  • Loading branch information
TheOneWithTheBraid committed Nov 12, 2023
1 parent fbaeb18 commit 8a3f886
Show file tree
Hide file tree
Showing 30 changed files with 935 additions and 255 deletions.
2 changes: 2 additions & 0 deletions assets/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file modified fonts/NotoEmoji/NotoColorEmoji.ttf
Binary file not shown.
Binary file added fonts/NotoSansSymbols-VariableFont_wght.ttf
Binary file not shown.
4 changes: 2 additions & 2 deletions lib/config/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion lib/config/themes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ abstract class FluffyThemes {

static const fallbackTextStyle = TextStyle(
fontFamily: 'Roboto',
fontFamilyFallback: ['NotoEmoji'],
fontFamilyFallback: [AppConfig.emojiFontName],
);

static var fallbackTextTheme = const TextTheme(
Expand Down
4 changes: 2 additions & 2 deletions lib/pages/bootstrap/bootstrap_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class BootstrapDialogState extends State<BootstrapDialog> {
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),
Expand Down Expand Up @@ -256,7 +256,7 @@ class BootstrapDialogState extends State<BootstrapDialog> {
? 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(
Expand Down
2 changes: 1 addition & 1 deletion lib/pages/chat/chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ class ChatController extends State<ChatPageWithRoom> {

// ignore: unawaited_futures
room.sendTextEvent(
sendController.text,
sendController.text.trim(),
inReplyTo: replyEvent,
editEventId: editEvent?.eventId,
parseCommands: parseCommands,
Expand Down
47 changes: 30 additions & 17 deletions lib/pages/chat/events/cute_events.dart
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
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<CuteContent> createState() => _CuteContentState();
}

class _CuteContentState extends State<CuteContent> {
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<User?>(
future: widget.event.fetchSenderUser(),
builder: (context, snapshot) {
Expand All @@ -40,9 +49,10 @@ class _CuteContentState extends State<CuteContent> {
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),
],
Expand Down Expand Up @@ -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),
),
),
);
}
Expand Down
130 changes: 122 additions & 8 deletions lib/pages/chat/events/html_message.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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 '<span data-fluffy-animated-emoji="$emoji">$emoji</span>';
},
);

final linkColor = textColor.withAlpha(150);

final blockquoteStyle = Style(
Expand All @@ -77,7 +93,7 @@ class HtmlMessage extends StatelessWidget {
return MouseRegion(
cursor: SystemMouseCursors.text,
child: Html(
data: linkifiedRenderHtml,
data: emojifiedHtml,
style: {
'*': Style(
color: textColor,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String> get supportedTags => {'img'};
Expand All @@ -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,
),
),
);
Expand Down Expand Up @@ -330,6 +375,7 @@ class MatrixMathExtension extends HtmlExtension {
final TextStyle? style;

MatrixMathExtension({this.style});

@override
Set<String> get supportedTags => {'div'};

Expand Down Expand Up @@ -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<String> 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<String> get supportedTags => {'code'};

Expand Down Expand Up @@ -400,6 +501,7 @@ class RoomPillExtension extends HtmlExtension {
final BuildContext context;

RoomPillExtension(this.context, this.room);

@override
Set<String> get supportedTags => {'a'};

Expand Down Expand Up @@ -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;
}
Loading

0 comments on commit 8a3f886

Please sign in to comment.