feat: add animated emoji support

- 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 <info@braid.business>
This commit is contained in:
The one with the braid 2023-07-24 21:26:02 +02:00 committed by The one with the braid
parent c149503541
commit dc8d77b88f
30 changed files with 841 additions and 173 deletions

View file

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

Binary file not shown.

View file

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

View file

@ -20,7 +20,7 @@ abstract class FluffyThemes {
static const fallbackTextStyle = TextStyle(
fontFamily: 'Roboto',
fontFamilyFallback: ['NotoEmoji'],
fontFamilyFallback: [AppConfig.emojiFontName],
);
static var fallbackTextTheme = const TextTheme(

View file

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

View file

@ -187,8 +187,6 @@ class ChatController extends State<ChatPageWithRoom> {
final int _loadHistoryCount = 100;
String inputText = '';
String pendingText = '';
bool showEmojiPicker = false;
@ -295,7 +293,6 @@ class ChatController extends State<ChatPageWithRoom> {
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<ChatPageWithRoom> {
// 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<ChatPageWithRoom> {
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<ChatPageWithRoom> {
if ((prefix.isNotEmpty) &&
text.toLowerCase() == '${prefix.toLowerCase()} ') {
setSendingClient(client);
setState(() {
inputText = '';
sendController.text = '';
});
sendController.text = '';
return;
}
}
@ -1233,7 +1229,6 @@ class ChatController extends State<ChatPageWithRoom> {
);
}
}
setState(() => inputText = text);
}
bool get isArchived =>
@ -1302,7 +1297,7 @@ class ChatController extends State<ChatPageWithRoom> {
void cancelReplyEventAction() => setState(() {
if (editEvent != null) {
inputText = sendController.text = pendingText;
sendController.text = pendingText;
pendingText = '';
}
replyEvent = null;

View file

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

View file

@ -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<CuteContent> createState() => _CuteContentState();
@ -18,17 +26,18 @@ class CuteContent extends StatefulWidget {
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) {
@ -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),
],
@ -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),
),
),
);
}

View file

@ -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 '<span data-fluffy-animated-emoji="$emoji">$emoji</span>';
},
);
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<String> 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<String> 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<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'};
@ -400,6 +501,7 @@ class RoomPillExtension extends HtmlExtension {
final BuildContext context;
RoomPillExtension(this.context, this.room);
@override
Set<String> 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;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Map<String, String?>> getSuggestions(String text) {
List<Map<String, String?>> 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<String, String?> suggestion) =>

View file

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

View file

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

View file

@ -62,7 +62,7 @@ class ImageViewerView extends StatelessWidget {
event: controller.widget.event,
fit: BoxFit.contain,
isThumbnail: false,
animated: true,
forceAnimation: true,
),
),
),

View file

@ -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<SettingsChat> {
@override
Widget build(BuildContext context) => SettingsChatView(this);
bool get autoplayAnimations =>
Matrix.of(context).client.autoplayAnimatedContent ?? !kIsWeb;
Future<void> 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<void> setAutoplayAnimatedContent(bool autoplay) async {
final elementWebData = accountData[_elementWebKey]?.content ?? {};
elementWebData['autoplayGifs'] = autoplay;
final uid = userID;
if (uid != null) {
await setAccountData(
uid,
_elementWebKey,
elementWebData,
);
}
}
}

View file

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

View file

@ -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<EmotesSettings> {
}
}
fitzpatrick get defaultTone => Matrix.of(context).client.defaultEmojiTone;
Future<void> 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<EmotesSettings> {
);
}
}
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<void> 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,
);
}
}
}

View file

@ -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: <Widget>[
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,
),
);
}

View file

@ -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 = <Widget>[];
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<AnimationEnabledContainerView> createState() =>
_AnimationEnabledContainerViewState();
}
class _AnimationEnabledContainerViewState
extends State<AnimationEnabledContainerView> {
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);
}
}

View file

@ -73,6 +73,8 @@ class Avatar extends StatelessWidget {
width: size,
height: size,
placeholder: (_) => textWidget,
watermarkSize: fontSize,
watermarkColor: Colors.white,
cacheKey: mxContent.toString(),
),
),

View file

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

View file

@ -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<Matrix> with WidgetsBindingObserver {
int _activeClient = -1;
String? activeBundle;
SharedPreferences get store => widget.store;
HomeserverSummary? loginHomeserverSummary;
@ -446,8 +448,18 @@ class MatrixState extends State<Matrix> 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) ??

View file

@ -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<MxcImage> {
static final Map<String, Uint8List> _imageDataCache = {};
Uint8List? _imageDataNoCache;
Uint8List? get _imageData {
static final Map<String, ImageFutureResponse> _imageDataCache = {};
ImageFutureResponse? _imageDataNoCache;
ImageFutureResponse? get _imageData {
final cacheKey = widget.cacheKey;
return cacheKey == null ? _imageDataNoCache : _imageDataCache[cacheKey];
}
set _imageData(Uint8List? data) {
/// asynchronously
Future<ImageFutureResponse> 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<ImageFutureResponse> _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<MxcImage> {
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<MxcImage> {
}
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<MxcImage> {
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<MxcImage> {
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);
}

View file

@ -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!,
),
),
],

View file

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

View file

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

View file

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