From 12c948211ba64e85887df6cdff5ceb647d16ee35 Mon Sep 17 00:00:00 2001 From: Krille Date: Thu, 30 Jan 2025 14:09:33 +0100 Subject: [PATCH] refactor: New html rendering --- lib/pages/chat/events/audio_player.dart | 25 +- lib/pages/chat/events/html_message.dart | 711 +++++++++--------- lib/pages/chat/events/image_bubble.dart | 23 +- lib/pages/chat/events/message.dart | 8 + lib/pages/chat/events/message_content.dart | 28 +- .../chat/events/message_download_content.dart | 34 +- lib/pages/chat/events/video_player.dart | 32 +- pubspec.lock | 40 - pubspec.yaml | 2 - 9 files changed, 467 insertions(+), 436 deletions(-) diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index 43d10ffea..8efcc190a 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:just_audio/just_audio.dart'; import 'package:matrix/matrix.dart'; import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; @@ -11,14 +12,15 @@ import 'package:path_provider/path_provider.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pages/chat/events/html_message.dart'; import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/file_description.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; import '../../../utils/matrix_sdk_extensions/event_extension.dart'; class AudioPlayerWidget extends StatefulWidget { final Color color; + final Color linkColor; final double fontSize; final Event event; @@ -28,7 +30,8 @@ class AudioPlayerWidget extends StatefulWidget { const AudioPlayerWidget( this.event, { - this.color = Colors.black, + required this.color, + required this.linkColor, required this.fontSize, super.key, }); @@ -392,10 +395,20 @@ class AudioPlayerState extends State { ), if (fileDescription != null) ...[ const SizedBox(height: 8), - HtmlMessage( - html: fileDescription, - textColor: widget.color, - room: widget.event.room, + Linkify( + text: fileDescription, + style: TextStyle( + color: widget.color, + fontSize: widget.fontSize, + ), + options: const LinkifyOptions(humanize: false), + linkStyle: TextStyle( + color: widget.linkColor, + fontSize: widget.fontSize, + decoration: TextDecoration.underline, + decorationColor: widget.linkColor, + ), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ), ], ], diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 4d97eb2d2..e4d28c8d1 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -3,13 +3,11 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:flutter_highlighter/flutter_highlighter.dart'; import 'package:flutter_highlighter/themes/shades-of-purple.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:flutter_html_table/flutter_html_table.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:html/dom.dart' as dom; -import 'package:linkify/linkify.dart'; +import 'package:html/parser.dart' as parser; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import '../../../utils/url_launcher.dart'; @@ -18,144 +16,21 @@ class HtmlMessage extends StatelessWidget { final String html; final Room room; final Color textColor; + final double fontSize; + final TextStyle linkStyle; + final void Function(LinkableElement) onOpen; const HtmlMessage({ super.key, required this.html, required this.room, + required this.fontSize, + required this.linkStyle, this.textColor = Colors.black, + required this.onOpen, }); - dom.Node _linkifyHtml(dom.Node element) { - for (final node in element.nodes) { - if (node is! dom.Text || - (element is dom.Element && element.localName == 'code')) { - node.replaceWith(_linkifyHtml(node)); - continue; - } - - final parts = linkify( - node.text, - options: const LinkifyOptions(humanize: false), - ); - - if (!parts.any((part) => part is UrlElement)) { - continue; - } - - final newHtml = parts - .map( - (linkifyElement) => linkifyElement is! UrlElement - ? linkifyElement.text.replaceAll('<', '<') - : '${linkifyElement.text}', - ) - .join(' '); - - node.replaceWith(dom.Element.html('

$newHtml

')); - } - return element; - } - - @override - Widget build(BuildContext context) { - final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; - - final linkColor = textColor.withAlpha(150); - - final blockquoteStyle = Style( - border: Border( - left: BorderSide( - width: 3, - color: textColor, - ), - ), - padding: HtmlPaddings.only(left: 6, bottom: 0), - ); - - final element = _linkifyHtml(HtmlParser.parseHTML(html)); - - // there is no need to pre-validate the html, as we validate it while rendering - return Html.fromElement( - documentElement: element as dom.Element, - style: { - '*': Style( - color: textColor, - margin: Margins.all(0), - fontSize: FontSize(fontSize), - ), - 'a': Style(color: linkColor, textDecorationColor: linkColor), - 'h1': Style( - fontSize: FontSize(fontSize * 2), - lineHeight: LineHeight.number(1.5), - fontWeight: FontWeight.w600, - ), - 'h2': Style( - fontSize: FontSize(fontSize * 1.75), - lineHeight: LineHeight.number(1.5), - fontWeight: FontWeight.w500, - ), - 'h3': Style( - fontSize: FontSize(fontSize * 1.5), - lineHeight: LineHeight.number(1.5), - ), - 'h4': Style( - fontSize: FontSize(fontSize * 1.25), - lineHeight: LineHeight.number(1.5), - ), - 'h5': Style( - fontSize: FontSize(fontSize * 1.25), - lineHeight: LineHeight.number(1.5), - ), - 'h6': Style( - fontSize: FontSize(fontSize), - lineHeight: LineHeight.number(1.5), - ), - 'blockquote': blockquoteStyle, - 'tg-forward': blockquoteStyle, - 'hr': Style( - border: Border.all(color: textColor, width: 0.5), - ), - 'table': Style( - border: Border.all(color: textColor, width: 0.5), - ), - 'tr': Style( - border: Border.all(color: textColor, width: 0.5), - ), - 'td': Style( - border: Border.all(color: textColor, width: 0.5), - padding: HtmlPaddings.all(2), - ), - 'th': Style( - border: Border.all(color: textColor, width: 0.5), - ), - }, - extensions: [ - RoomPillExtension(context, room, fontSize, linkColor), - CodeExtension(fontSize: fontSize), - const TableHtmlExtension(), - SpoilerExtension(textColor: textColor), - const ImageExtension(), - FontColorExtension(), - FallbackTextExtension(fontSize: fontSize), - ], - onLinkTap: (url, _, element) => UrlLauncher( - context, - url, - element?.text, - ).launchUrl(), - onlyRenderTheseTags: const { - ...allowedHtmlTags, - // Needed to make it work properly - 'body', - 'html', - }, - shrinkWrap: true, - ); - } - - static const Set fallbackTextTags = {'tg-forward'}; - - /// Keep in sync with: https://spec.matrix.org/v1.6/client-server-api/#mroommessage-msgtypes + /// Keep in sync with: https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes static const Set allowedHtmlTags = { 'font', 'del', @@ -199,255 +74,352 @@ class HtmlMessage extends StatelessWidget { 'ruby', 'rp', 'rt', + 'html', + 'body', // Workaround for https://github.com/krille-chan/fluffychat/issues/507 - ...fallbackTextTags, + 'tg-forward', }; -} - -class FontColorExtension extends HtmlExtension { - static const String colorAttribute = 'color'; - static const String mxColorAttribute = 'data-mx-color'; - static const String bgColorAttribute = 'data-mx-bg-color'; - - @override - Set get supportedTags => {'font', 'span'}; - - @override - bool matches(ExtensionContext context) { - if (!supportedTags.contains(context.elementName)) return false; - return context.element?.attributes.keys.any( - { - colorAttribute, - mxColorAttribute, - bgColorAttribute, - }.contains, - ) ?? - false; - } - Color? hexToColor(String? hexCode) { - if (hexCode == null) return null; - if (hexCode.startsWith('#')) hexCode = hexCode.substring(1); - if (hexCode.length == 6) hexCode = 'FF$hexCode'; - final colorValue = int.tryParse(hexCode, radix: 16); - return colorValue == null ? null : Color(colorValue); - } - - @override - InlineSpan build( - ExtensionContext context, - ) { - final colorText = context.element?.attributes[colorAttribute] ?? - context.element?.attributes[mxColorAttribute]; - final bgColor = context.element?.attributes[bgColorAttribute]; - return TextSpan( - style: TextStyle( - color: hexToColor(colorText), - backgroundColor: hexToColor(bgColor), - ), - text: context.innerHtml, - ); - } -} - -class ImageExtension extends HtmlExtension { - final double defaultDimension; - - const ImageExtension({this.defaultDimension = 64}); + /// We add line breaks before these tags: + static const Set blockHtmlTags = { + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'pre', + 'br', + 'div', + 'table', + 'blockquote', + 'details', + }; - @override - Set get supportedTags => {'img'}; + /// Adding line breaks before block elements. + List _renderWithLineBreaks( + dom.NodeList nodes, + BuildContext context, { + int depth = 1, + }) => + [ + for (var i = 0; i < nodes.length; i++) ...[ + if (i > 0 && + nodes[i] is dom.Element && + blockHtmlTags.contains((nodes[i] as dom.Element).localName)) + const TextSpan(text: '\n'), // Add linebreak + // Actually render the node child: + _renderHtml(nodes[i], context, depth: depth + 1), + ], + ]; + + /// Transforms a Node to an InlineSpan. + InlineSpan _renderHtml( + dom.Node node, + BuildContext context, { + int depth = 1, + }) { + // We must not render elements nested more than 100 elements deep: + if (depth >= 100) return const TextSpan(); + + // This is a text node, so we render it as text: + if (node is! dom.Element) { + // Inside of a list so we add some prefix text: + var text = node.text ?? ''; + if (node.parent?.localName == 'li') { + if (node.parent?.parent?.localName == 'ul') { + text = '◦ $text'; + } + if (node.parent?.parent?.localName == 'ol') { + text = + '${(node.parent?.parent?.nodes.indexOf(node.parent) ?? 0) + 1}. $text'; + } + if (node.parent?.parent?.parent?.localName == 'li') { + text = ' $text'; + } + } - @override - InlineSpan build(ExtensionContext context) { - final mxcUrl = Uri.tryParse(context.attributes['src'] ?? ''); - if (mxcUrl == null || mxcUrl.scheme != 'mxc') { - return TextSpan(text: context.attributes['alt']); + return LinkifySpan( + text: text, + options: const LinkifyOptions(humanize: false), + linkStyle: linkStyle, + onOpen: onOpen, + ); } - final width = double.tryParse(context.attributes['width'] ?? ''); - final height = double.tryParse(context.attributes['height'] ?? ''); - - final actualWidth = width ?? height ?? defaultDimension; - final actualHeight = height ?? width ?? defaultDimension; - - return WidgetSpan( - child: SizedBox( - width: actualWidth, - height: actualHeight, - child: MxcImage( - uri: mxcUrl, - width: actualWidth, - height: actualHeight, - isThumbnail: (actualWidth * actualHeight) > (256 * 256), - ), - ), - ); - } -} - -class SpoilerExtension extends HtmlExtension { - final Color textColor; - - const SpoilerExtension({required this.textColor}); - - @override - Set get supportedTags => {'span'}; - - static const String customDataAttribute = 'data-mx-spoiler'; - - @override - bool matches(ExtensionContext context) { - if (context.elementName != 'span') return false; - return context.element?.attributes.containsKey(customDataAttribute) ?? - false; - } - - @override - InlineSpan build(ExtensionContext context) { - var obscure = true; - final children = context.inlineSpanChildren; - return WidgetSpan( - child: StatefulBuilder( - builder: (context, setState) { - return InkWell( - onTap: () => setState(() { - obscure = !obscure; - }), - child: RichText( - text: TextSpan( - style: obscure ? TextStyle(backgroundColor: textColor) : null, - children: children, + // We must not render tags which are not in the allow list: + if (!allowedHtmlTags.contains(node.localName)) return const TextSpan(); + + switch (node.localName) { + case 'a': + final href = node.attributes['href']; + if (href == null) continue block; + final matrixId = node.attributes['href'] + ?.parseIdentifierIntoParts() + ?.primaryIdentifier; + if (matrixId != null) { + if (matrixId.sigil == '@') { + final user = room.unsafeGetUserFromMemoryOrFallback(matrixId); + return WidgetSpan( + child: MatrixPill( + key: Key('user_pill_$matrixId'), + name: user.calcDisplayname(), + avatar: user.avatarUrl, + uri: href, + outerContext: context, + fontSize: fontSize, + color: textColor, + ), + ); + } + if (matrixId.sigil == '#' || matrixId.sigil == '!') { + final room = matrixId.sigil == '!' + ? this.room.client.getRoomById(matrixId) + : this.room.client.getRoomByAlias(matrixId); + return WidgetSpan( + child: MatrixPill( + name: room?.getLocalizedDisplayname() ?? matrixId, + avatar: room?.avatar, + uri: href, + outerContext: context, + fontSize: fontSize, + color: textColor, ), + ); + } + } + return WidgetSpan( + child: InkWell( + splashColor: Colors.transparent, + onTap: () => + UrlLauncher(context, node.attributes['href'], node.text) + .launchUrl(), + child: Text.rich( + TextSpan( + children: _renderWithLineBreaks( + node.nodes, + context, + depth: depth, + ), + style: linkStyle, + ), + style: const TextStyle(height: 1), ), - ); - }, - ), - ); - } -} - -class CodeExtension extends HtmlExtension { - final double fontSize; - - CodeExtension({required this.fontSize}); - @override - Set get supportedTags => {'code'}; - - @override - InlineSpan build(ExtensionContext context) => WidgetSpan( - child: Material( - clipBehavior: Clip.hardEdge, - borderRadius: BorderRadius.circular(4), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: HighlightView( - context.element?.text ?? '', - language: context.element?.className - .split(' ') - .singleWhereOrNull( - (className) => className.startsWith('language-'), - ) - ?.split('language-') - .last ?? - 'md', - theme: shadesOfPurpleTheme, - padding: EdgeInsets.symmetric( - horizontal: 6, - vertical: context.element?.parent?.localName == 'pre' ? 6 : 0, + ), + ); + case 'blockquote': + return WidgetSpan( + child: Container( + padding: const EdgeInsets.only(left: 8.0), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: textColor, + width: 3, + ), + ), + ), + child: Text.rich( + TextSpan( + children: _renderWithLineBreaks( + node.nodes, + context, + depth: depth, + ), + ), + style: TextStyle( + fontStyle: FontStyle.italic, + fontSize: fontSize, + color: textColor, ), - textStyle: TextStyle(fontSize: fontSize), ), ), - ), - ); -} - -class FallbackTextExtension extends HtmlExtension { - final double fontSize; - - FallbackTextExtension({required this.fontSize}); - @override - Set get supportedTags => HtmlMessage.fallbackTextTags; - - @override - InlineSpan build(ExtensionContext context) => TextSpan( - text: context.element?.text ?? '', - style: TextStyle( - fontSize: fontSize, - ), - ); -} - -class RoomPillExtension extends HtmlExtension { - final Room room; - final BuildContext context; - final double fontSize; - final Color color; - - RoomPillExtension(this.context, this.room, this.fontSize, this.color); - @override - Set get supportedTags => {'a'}; - - @override - bool matches(ExtensionContext context) { - if (context.elementName != 'a') return false; - final userId = context.element?.attributes['href'] - ?.parseIdentifierIntoParts() - ?.primaryIdentifier; - return userId != null; - } - - static final _cachedUsers = {}; - - Future _fetchUser(String matrixId) async => - _cachedUsers[room.id + matrixId] ??= await room.requestUser(matrixId); + ); + case 'code': + final isInline = node.parent?.localName != 'pre'; + return WidgetSpan( + child: Material( + clipBehavior: Clip.hardEdge, + borderRadius: BorderRadius.circular(4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: HighlightView( + node.text, + language: node.className + .split(' ') + .singleWhereOrNull( + (className) => className.startsWith('language-'), + ) + ?.split('language-') + .last ?? + 'md', + theme: shadesOfPurpleTheme, + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: isInline ? 0 : 8, + ), + textStyle: TextStyle(fontSize: fontSize), + ), + ), + ), + ); + case 'img': + final mxcUrl = Uri.tryParse(node.attributes['src'] ?? ''); + if (mxcUrl == null || mxcUrl.scheme != 'mxc') { + return TextSpan(text: node.attributes['alt']); + } + + final width = double.tryParse(node.attributes['width'] ?? ''); + final height = double.tryParse(node.attributes['height'] ?? ''); + const defaultDimension = 64.0; + final actualWidth = width ?? height ?? defaultDimension; + final actualHeight = height ?? width ?? defaultDimension; - @override - InlineSpan build(ExtensionContext context) { - final href = context.element?.attributes['href']; - final matrixId = href?.parseIdentifierIntoParts()?.primaryIdentifier; - if (href == null || matrixId == null) { - return TextSpan(text: context.innerHtml); - } - if (matrixId.sigil == '@') { - return WidgetSpan( - child: FutureBuilder( - future: _fetchUser(matrixId), - builder: (context, snapshot) => MatrixPill( - key: Key('user_pill_$matrixId'), - name: _cachedUsers[room.id + matrixId]?.calcDisplayname() ?? - matrixId.localpart ?? - matrixId, - avatar: _cachedUsers[room.id + matrixId]?.avatarUrl, - uri: href, - outerContext: this.context, - fontSize: fontSize, - color: color, + return WidgetSpan( + child: SizedBox( + width: actualWidth, + height: actualHeight, + child: MxcImage( + uri: mxcUrl, + width: actualWidth, + height: actualHeight, + isThumbnail: (actualWidth * actualHeight) > (256 * 256), + ), ), - ), - ); - } - if (matrixId.sigil == '#' || matrixId.sigil == '!') { - final room = matrixId.sigil == '!' - ? this.room.client.getRoomById(matrixId) - : this.room.client.getRoomByAlias(matrixId); - if (room != null) { + ); + case 'hr': + return const WidgetSpan(child: Divider()); + case 'details': + var obscure = true; return WidgetSpan( - child: MatrixPill( - name: room.getLocalizedDisplayname(), - avatar: room.avatar, - uri: href, - outerContext: this.context, - fontSize: fontSize, - color: color, + child: StatefulBuilder( + builder: (context, setState) => InkWell( + splashColor: Colors.transparent, + onTap: () => setState(() { + obscure = !obscure; + }), + child: Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Icon( + obscure ? Icons.arrow_right : Icons.arrow_drop_down, + size: fontSize * 1.2, + color: textColor, + ), + ), + if (obscure) + ...node.nodes + .where( + (node) => + node is dom.Element && + node.localName == 'summary', + ) + .map( + (node) => _renderHtml(node, context, depth: depth), + ) + else + ..._renderWithLineBreaks( + node.nodes, + context, + depth: depth, + ), + ], + ), + style: TextStyle( + fontSize: fontSize, + color: textColor, + ), + ), + ), + ), + ); + case 'span': + if (!node.attributes.containsKey('data-mx-spoiler')) { + continue block; + } + var obscure = true; + return WidgetSpan( + child: StatefulBuilder( + builder: (context, setState) => InkWell( + splashColor: Colors.transparent, + onTap: () => setState(() { + obscure = !obscure; + }), + child: Text.rich( + TextSpan( + children: _renderWithLineBreaks( + node.nodes, + context, + depth: depth, + ), + ), + style: TextStyle( + fontSize: fontSize, + color: textColor, + backgroundColor: obscure ? textColor : null, + ), + ), + ), + ), + ); + block: + default: + return TextSpan( + style: switch (node.localName) { + 'body' => TextStyle( + fontSize: fontSize, + color: textColor, + ), + 'a' => linkStyle, + 'strong' => const TextStyle(fontWeight: FontWeight.bold), + 'em' || 'i' => const TextStyle(fontStyle: FontStyle.italic), + 'del' || + 'strikethrough' => + const TextStyle(decoration: TextDecoration.lineThrough), + 'u' => const TextStyle(decoration: TextDecoration.underline), + 'h1' => TextStyle(fontSize: fontSize * 1.6), + 'h2' => TextStyle(fontSize: fontSize * 1.5), + 'h3' => TextStyle(fontSize: fontSize * 1.4), + 'h4' => TextStyle(fontSize: fontSize * 1.3), + 'h5' => TextStyle(fontSize: fontSize * 1.2), + 'h6' => TextStyle(fontSize: fontSize * 1.1), + 'span' => TextStyle( + color: node.attributes['color']?.hexToColor ?? + node.attributes['data-mx-color']?.hexToColor ?? + textColor, + backgroundColor: + node.attributes['data-mx-bg-color']?.hexToColor, + ), + 'sup' => + const TextStyle(fontFeatures: [FontFeature.superscripts()]), + 'sub' => const TextStyle(fontFeatures: [FontFeature.subscripts()]), + _ => null, + }, + children: _renderWithLineBreaks( + node.nodes, + context, + depth: depth, ), ); - } } - - return TextSpan(text: context.innerHtml); } + + @override + Widget build(BuildContext context) => Text.rich( + _renderHtml( + parser.parse(html).body ?? dom.Element.html(''), + context, + ), + style: TextStyle( + fontSize: fontSize, + color: textColor, + ), + ); } class MatrixPill extends StatelessWidget { @@ -471,6 +443,7 @@ class MatrixPill extends StatelessWidget { @override Widget build(BuildContext context) { return InkWell( + splashColor: Colors.transparent, onTap: UrlLauncher(outerContext, uri).launchUrl, child: Row( mainAxisSize: MainAxisSize.min, @@ -496,3 +469,13 @@ class MatrixPill extends StatelessWidget { ); } } + +extension on String { + Color? get hexToColor { + var hexCode = this; + if (hexCode.startsWith('#')) hexCode = hexCode.substring(1); + if (hexCode.length == 6) hexCode = 'FF$hexCode'; + final colorValue = int.tryParse(hexCode, radix: 16); + return colorValue == null ? null : Color(colorValue); + } +} diff --git a/lib/pages/chat/events/image_bubble.dart b/lib/pages/chat/events/image_bubble.dart index 605561ae2..6c9a788ac 100644 --- a/lib/pages/chat/events/image_bubble.dart +++ b/lib/pages/chat/events/image_bubble.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.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/events/html_message.dart'; import 'package:fluffychat/pages/image_viewer/image_viewer.dart'; import 'package:fluffychat/utils/file_description.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import '../../../widgets/blur_hash.dart'; @@ -16,6 +17,7 @@ class ImageBubble extends StatelessWidget { final bool maxSize; final Color? backgroundColor; final Color? textColor; + final Color? linkColor; final bool thumbnailOnly; final bool animated; final double width; @@ -38,6 +40,7 @@ class ImageBubble extends StatelessWidget { this.borderRadius, this.timeline, this.textColor, + this.linkColor, super.key, }); @@ -121,10 +124,20 @@ class ImageBubble extends StatelessWidget { if (fileDescription != null && textColor != null) SizedBox( width: width, - child: HtmlMessage( - html: fileDescription, - textColor: textColor, - room: event.room, + child: Linkify( + text: fileDescription, + style: TextStyle( + color: textColor, + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, + ), + options: const LinkifyOptions(humanize: false), + linkStyle: TextStyle( + color: linkColor, + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ), ), ], diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 542637b59..32ab44ab0 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -112,6 +112,13 @@ class Message extends StatelessWidget { ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer : theme.colorScheme.onSurface; + + final linkColor = ownMessage + ? theme.brightness == Brightness.light + ? theme.colorScheme.primaryFixed + : theme.colorScheme.onTertiaryContainer + : theme.colorScheme.primary; + final rowMainAxisAlignment = ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start; @@ -393,6 +400,7 @@ class Message extends StatelessWidget { MessageContent( displayEvent, textColor: textColor, + linkColor: linkColor, onInfoTab: onInfoTab, borderRadius: borderRadius, timeline: timeline, diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 87c1809c0..40e237abd 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -26,6 +26,7 @@ import 'message_download_content.dart'; class MessageContent extends StatelessWidget { final Event event; final Color textColor; + final Color linkColor; final void Function(Event)? onInfoTab; final BorderRadius borderRadius; final Timeline timeline; @@ -36,6 +37,7 @@ class MessageContent extends StatelessWidget { super.key, required this.timeline, required this.textColor, + required this.linkColor, required this.borderRadius, }); @@ -155,14 +157,23 @@ class MessageContent extends StatelessWidget { return AudioPlayerWidget( event, color: textColor, + linkColor: linkColor, fontSize: fontSize, ); } - return MessageDownloadContent(event, textColor); + return MessageDownloadContent( + event, + textColor: textColor, + linkColor: linkColor, + ); case MessageTypes.Video: return EventVideoPlayer(event, textColor: textColor); case MessageTypes.File: - return MessageDownloadContent(event, textColor); + return MessageDownloadContent( + event, + textColor: textColor, + linkColor: linkColor, + ); case MessageTypes.Text: case MessageTypes.Notice: @@ -178,6 +189,15 @@ class MessageContent extends StatelessWidget { html: html, textColor: textColor, room: event.room, + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, + linkStyle: TextStyle( + color: linkColor, + fontSize: + AppConfig.fontSizeFactor * AppConfig.messageFontSize, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ); } // else we fall through to the normal message rendering @@ -268,10 +288,10 @@ class MessageContent extends StatelessWidget { ), options: const LinkifyOptions(humanize: false), linkStyle: TextStyle( - color: textColor.withAlpha(150), + color: linkColor, fontSize: fontSize, decoration: TextDecoration.underline, - decorationColor: textColor.withAlpha(150), + decorationColor: linkColor, ), onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ); diff --git a/lib/pages/chat/events/message_download_content.dart b/lib/pages/chat/events/message_download_content.dart index b40325089..b759895ef 100644 --- a/lib/pages/chat/events/message_download_content.dart +++ b/lib/pages/chat/events/message_download_content.dart @@ -1,16 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pages/chat/events/html_message.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/file_description.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; class MessageDownloadContent extends StatelessWidget { final Event event; final Color textColor; + final Color linkColor; - const MessageDownloadContent(this.event, this.textColor, {super.key}); + const MessageDownloadContent( + this.event, { + required this.textColor, + required this.linkColor, + super.key, + }); @override Widget build(BuildContext context) { @@ -66,7 +74,7 @@ class MessageDownloadContent extends StatelessWidget { Text( filetype, style: TextStyle( - color: textColor.withAlpha(150), + color: linkColor, ), ), const Spacer(), @@ -74,7 +82,7 @@ class MessageDownloadContent extends StatelessWidget { Text( sizeString, style: TextStyle( - color: textColor.withAlpha(150), + color: linkColor, ), ), ], @@ -84,10 +92,20 @@ class MessageDownloadContent extends StatelessWidget { ), ), if (fileDescription != null) - HtmlMessage( - html: fileDescription, - textColor: textColor, - room: event.room, + Linkify( + text: fileDescription, + style: TextStyle( + color: textColor, + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, + ), + options: const LinkifyOptions(humanize: false), + linkStyle: TextStyle( + color: linkColor, + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ), ], ); diff --git a/lib/pages/chat/events/video_player.dart b/lib/pages/chat/events/video_player.dart index 0ea894eed..ad3dfaf15 100644 --- a/lib/pages/chat/events/video_player.dart +++ b/lib/pages/chat/events/video_player.dart @@ -5,25 +5,32 @@ import 'package:flutter/material.dart'; import 'package:chewie/chewie.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:matrix/matrix.dart'; import 'package:path_provider/path_provider.dart'; import 'package:universal_html/html.dart' as html; import 'package:video_player/video_player.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pages/chat/events/html_message.dart'; import 'package:fluffychat/pages/chat/events/image_bubble.dart'; import 'package:fluffychat/utils/file_description.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/blur_hash.dart'; import '../../../utils/error_reporter.dart'; class EventVideoPlayer extends StatefulWidget { final Event event; final Color? textColor; - const EventVideoPlayer(this.event, {this.textColor, super.key}); + final Color? linkColor; + const EventVideoPlayer( + this.event, { + this.textColor, + this.linkColor, + super.key, + }); @override EventVideoPlayerState createState() => EventVideoPlayerState(); @@ -107,6 +114,7 @@ class EventVideoPlayerState extends State { fallbackBlurHash; final fileDescription = widget.event.fileDescription; final textColor = widget.textColor; + final linkColor = widget.linkColor; const width = 300.0; @@ -164,13 +172,23 @@ class EventVideoPlayerState extends State { ), ), ), - if (fileDescription != null && textColor != null) + if (fileDescription != null && textColor != null && linkColor != null) SizedBox( width: width, - child: HtmlMessage( - html: fileDescription, - textColor: textColor, - room: widget.event.room, + child: Linkify( + text: fileDescription, + style: TextStyle( + color: textColor, + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, + ), + options: const LinkifyOptions(humanize: false), + linkStyle: TextStyle( + color: linkColor, + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ), ), ], diff --git a/pubspec.lock b/pubspec.lock index 24878fa89..b423ecb40 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -472,22 +472,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.1" - flutter_html: - dependency: "direct main" - description: - name: flutter_html - sha256: "02ad69e813ecfc0728a455e4bf892b9379983e050722b1dce00192ee2e41d1ee" - url: "https://pub.dev" - source: hosted - version: "3.0.0-beta.2" - flutter_html_table: - dependency: "direct main" - description: - name: flutter_html_table - sha256: e20c72d67ea2512e7b4949f6f7dd13d004e773b0f82c586a21f895e6bd90383c - url: "https://pub.dev" - source: hosted - version: "3.0.0-beta.2" flutter_keyboard_visibility: dependency: transitive description: @@ -536,14 +520,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - flutter_layout_grid: - dependency: transitive - description: - name: flutter_layout_grid - sha256: "88b4f8484a0874962e27c47733ad256aeb26acc694a9f029edbef771d301885a" - url: "https://pub.dev" - source: hosted - version: "2.0.7" flutter_linkify: dependency: "direct main" description: @@ -1090,14 +1066,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - list_counter: - dependency: transitive - description: - name: list_counter - sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 - url: "https://pub.dev" - source: hosted - version: "1.0.2" lists: dependency: transitive description: @@ -1538,14 +1506,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - quiver: - dependency: transitive - description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" - source: hosted - version: "3.2.2" random_string: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8f67c5d6b..f533a1bad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,8 +33,6 @@ dependencies: flutter_cache_manager: ^3.4.1 flutter_foreground_task: ^6.1.3 flutter_highlighter: ^0.1.1 - flutter_html: ^3.0.0-beta.2 - flutter_html_table: ^3.0.0-beta.2 flutter_linkify: ^6.0.0 flutter_local_notifications: ^17.2.3 flutter_localizations: