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_math_fork/flutter_math.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/widgets/avatar.dart'; import '../../../utils/url_launcher.dart'; class HtmlMessage extends StatelessWidget { final String html; final Room room; final Color? textColor; const HtmlMessage({ Key? key, required this.html, required this.room, this.textColor, }) : super(key: key); @override Widget build(BuildContext context) { // riot-web is notorious for creating bad reply fallback events from invalid messages which, if // not handled properly, can lead to impersination. As such, we strip the entire `` tags // here already, to prevent that from happening. // We do *not* do this in an AST and just with simple regex here, as riot-web tends to create // miss-matching tags, and this way we actually correctly identify what we want to strip and, well, // strip it. final renderHtml = html.replaceAll( RegExp( '.*', caseSensitive: false, multiLine: false, dotAll: true, ), '', ); final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; // there is no need to pre-validate the html, as we validate it while rendering return Html( data: renderHtml, style: { '*': Style( color: textColor, margin: Margins.all(0), fontSize: FontSize(fontSize), ), 'a': Style(color: textColor?.withAlpha(150)), 'h1': Style(fontSize: FontSize(fontSize * 3)), 'h2': Style(fontSize: FontSize(fontSize * 2.5)), 'h3': Style(fontSize: FontSize(fontSize * 2)), 'h4': Style(fontSize: FontSize(fontSize * 1.75)), 'h5': Style(fontSize: FontSize(fontSize * 1.5)), 'h6': Style(fontSize: FontSize(fontSize * 1.25)), 'blockquote': Style( border: Border( left: BorderSide( width: 3, color: textColor ?? Colors.black, ), ), padding: const EdgeInsets.only(left: 6), ), // Otherwise list tiles are all in the same row$$ 'li': Style(display: Display.block), }, extensions: [ UserPillExtension(context, room), RoomPillExtension(context, room.client), CodeExtension(fontSize: fontSize), MatrixMathExtension( style: TextStyle(fontSize: fontSize, color: textColor), ), ], onLinkTap: (url, _, __) => UrlLauncher(context, url).launchUrl(), onlyRenderTheseTags: const { ...allowedHtmlTags, // Needed to make it work properly 'body', 'html', }, shrinkWrap: true, ); } /// Keep in sync with: https://spec.matrix.org/v1.6/client-server-api/#mroommessage-msgtypes static const Set allowedHtmlTags = { 'font', 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'caption', 'pre', 'span', // TODO: Implement image extension for Mxc URIs //'img', 'details', 'summary' }; } class MatrixMathExtension extends HtmlExtension { final TextStyle? style; MatrixMathExtension({this.style}); @override Set get supportedTags => {'div'}; @override bool matches(ExtensionContext context) { if (context.elementName != 'div') return false; final mathData = context.element?.attributes['data-mx-maths']; return mathData != null; } @override InlineSpan build( ExtensionContext context, Map Function() parseChildren, ) { final data = context.element?.attributes['data-mx-maths'] ?? ''; return WidgetSpan( child: Math.tex( data, textStyle: style, onErrorFallback: (e) { Logs().d('Flutter math parse error', e); return Text( data, style: style, ); }, ), ); } } class CodeExtension extends HtmlExtension { final double fontSize; CodeExtension({required this.fontSize}); @override Set get supportedTags => {'code'}; @override InlineSpan build( ExtensionContext context, Map Function() parseChildren, ) => 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: const EdgeInsets.all(6), textStyle: TextStyle(fontSize: fontSize), ), ), ), ); } class UserPillExtension extends HtmlExtension { final Room room; final BuildContext context; UserPillExtension(this.context, this.room); @override Set get supportedTags => {'a'}; @override bool matches(ExtensionContext context) { if (context.elementName != 'a') return false; final href = context.element?.attributes['href']; if (href == null) return false; final uri = Uri.tryParse(href); if (uri == null || uri.host != 'matrix.to') return false; final userId = uri.fragment.split('/').last; if (userId.sigil != '@') return false; return true; } @override InlineSpan build( ExtensionContext context, Map Function() parseChildren, ) { final href = context.element!.attributes['href']!; final uri = Uri.parse(href); final userId = uri.fragment.split('/').last; final user = room.unsafeGetUserFromMemoryOrFallback(userId); return WidgetSpan( child: MatrixPill( name: user.calcDisplayname(), avatar: user.avatarUrl, uri: href, outerContext: this.context, ), ); } } class RoomPillExtension extends HtmlExtension { final Client client; final BuildContext context; RoomPillExtension(this.context, this.client); @override Set get supportedTags => {'a'}; @override bool matches(ExtensionContext context) { if (context.elementName != 'a') return false; final href = context.element?.attributes['href']; if (href == null) return false; final uri = Uri.tryParse(href); if (uri == null || uri.host != 'matrix.to') return false; final roomId = Uri.decodeComponent(uri.fragment.split('/').last); if (!{'#', '!'}.contains(roomId.sigil)) return false; return true; } @override InlineSpan build( ExtensionContext context, Map Function() parseChildren, ) { final href = context.element!.attributes['href']!; final uri = Uri.parse(href); final roomId = Uri.decodeComponent(uri.fragment.split('/').last); final room = roomId.sigil == '!' ? client.getRoomById(roomId) : client.getRoomByAlias(roomId); if (room != null) { return WidgetSpan( child: MatrixPill( name: room.getLocalizedDisplayname(), avatar: room.avatar, uri: href, outerContext: this.context, ), ); } return WidgetSpan( child: FutureBuilder( future: client.queryPublicRooms( server: roomId.domain, filter: PublicRoomQueryFilter( genericSearchTerm: roomId, ), ), builder: (context, snapshot) { final room = snapshot.data; return MatrixPill( name: room?.chunk.singleOrNull?.name ?? roomId, avatar: room?.chunk.singleOrNull?.avatarUrl, uri: href, outerContext: this.context, ); }, ), ); } } class MatrixPill extends StatelessWidget { final String name; final BuildContext outerContext; final Uri? avatar; final String uri; const MatrixPill({ super.key, required this.name, required this.outerContext, this.avatar, required this.uri, }); @override Widget build(BuildContext context) { return InkWell( onTap: UrlLauncher(outerContext, uri).launchUrl, child: Material( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppConfig.borderRadius), side: BorderSide( color: Theme.of(outerContext).colorScheme.onPrimaryContainer, width: 0.5, ), ), color: Theme.of(outerContext).colorScheme.primaryContainer, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ Avatar( mxContent: avatar, name: name, size: 16, ), const SizedBox(width: 6), Text( name, style: TextStyle( color: Theme.of(outerContext).colorScheme.onPrimaryContainer, ), ), ], ), ), ), ); } }