From f59b31ce9c52af3cc9a62d130ce73ad2e2586e82 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 21 Jan 2025 12:54:48 -0500 Subject: [PATCH] feat: add clicking for tokens in HTML messages (#1510) --- lib/pages/chat/events/html_message.dart | 152 ++++++++++++++++++++- lib/pages/chat/events/message_content.dart | 49 ++++--- 2 files changed, 178 insertions(+), 23 deletions(-) diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 866b680c6..8e625f031 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -10,7 +11,9 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import '../../../utils/url_launcher.dart'; @@ -26,6 +29,9 @@ class HtmlMessage extends StatelessWidget { final Event event; final Event? nextEvent; final Event? prevEvent; + + final bool Function(PangeaToken)? isSelected; + final void Function(PangeaToken)? onClick; // Pangea# const HtmlMessage({ @@ -40,6 +46,8 @@ class HtmlMessage extends StatelessWidget { required this.controller, this.nextEvent, this.prevEvent, + this.isSelected, + this.onClick, // Pangea# }); @@ -73,6 +81,73 @@ class HtmlMessage extends StatelessWidget { return element; } + // #Pangea + List? get tokens => + pangeaMessageEvent!.messageDisplayRepresentation?.tokens; + + PangeaToken? getToken( + String text, + int offset, + int length, + ) => + tokens?.firstWhereOrNull( + (token) => token.text.offset == offset && token.text.length == length, + ); + + /// Wrap token spans in token tags so styling / functions can be applied + dom.Node _tokenizeHtml( + dom.Node element, + String fullHtml, + List remainingTokens, + ) { + for (final node in element.nodes) { + node.replaceWith(_tokenizeHtml(node, fullHtml, remainingTokens)); + } + + if (element is dom.Text) { + // once a text element in reached in the HTML tree, find and + // wrap all the spans with matching tokens until all tokens + // have been wrapped or no more text elements remain + String tokenizedText = element.text; + while (remainingTokens.isNotEmpty) { + final tokenText = remainingTokens.first.text.content; + + int startIndex = tokenizedText.lastIndexOf(''); + startIndex = startIndex == -1 ? 0 : startIndex + 8; + final int tokenIndex = tokenizedText.indexOf( + tokenText, + startIndex, + ); + + // if the token is not found in the text, check if the token exist in the full HTML. + // If not, remove the token and continue. If so, break to move on to the next node in the HTML. + if (tokenIndex == -1) { + final fullHtmlIndex = fullHtml.indexOf(tokenText); + if (fullHtmlIndex == -1) { + remainingTokens.removeAt(0); + continue; + } else { + break; + } + } + + final token = remainingTokens.removeAt(0); + final tokenEnd = tokenIndex + tokenText.length; + final before = tokenizedText.substring(0, tokenIndex); + final after = tokenizedText.substring(tokenEnd); + + tokenizedText = + "$before$tokenText$after"; + } + + final newElement = dom.Element.html('$tokenizedText'); + return newElement; + } + + return element; + } + // Pangea# + @override Widget build(BuildContext context) { final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; @@ -89,7 +164,24 @@ class HtmlMessage extends StatelessWidget { padding: HtmlPaddings.only(left: 6, bottom: 0), ); - final element = _linkifyHtml(HtmlParser.parseHTML(html)); + // #Pangea + // final element = _linkifyHtml(HtmlParser.parseHTML(html)); + dom.Node element = _linkifyHtml(HtmlParser.parseHTML(html)); + if (tokens != null && element is dom.Element) { + try { + element = _tokenizeHtml(element, element.innerHtml, List.from(tokens!)); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'html': html, + 'tokens': tokens, + }, + ); + } + } + // Pangea# // there is no need to pre-validate the html, as we validate it while rendering // #Pangea @@ -170,6 +262,18 @@ class HtmlMessage extends StatelessWidget { const ImageExtension(), FontColorExtension(), FallbackTextExtension(fontSize: fontSize), + // #Pangea + if (pangeaMessageEvent != null) + TokenExtension( + style: AppConfig.messageTextStyle( + pangeaMessageEvent!.event, + textColor, + ), + getToken: getToken, + isSelected: isSelected, + onClick: onClick, + ), + // Pangea# ], onLinkTap: (url, _, element) => UrlLauncher( context, @@ -236,9 +340,55 @@ class HtmlMessage extends StatelessWidget { 'rt', // Workaround for https://github.com/krille-chan/fluffychat/issues/507 ...fallbackTextTags, + // #Pangea + 'token', + // Pangea# }; } +// #Pangea +class TokenExtension extends HtmlExtension { + final TextStyle style; + final PangeaToken? Function(String, int, int) getToken; + final bool Function(PangeaToken)? isSelected; + final void Function(PangeaToken)? onClick; + + const TokenExtension({ + required this.style, + required this.getToken, + this.isSelected, + this.onClick, + }); + + @override + Set get supportedTags => {'token'}; + + @override + InlineSpan build(ExtensionContext context) { + final token = getToken( + context.attributes['offset'] ?? '', + int.tryParse(context.attributes['offset'] ?? '') ?? 0, + int.tryParse(context.attributes['length'] ?? '') ?? 0, + ); + + final selected = + token != null && isSelected != null ? isSelected!.call(token) : false; + + final backgroundColor = + selected ? AppConfig.primaryColor.withAlpha(80) : Colors.transparent; + + return TextSpan( + recognizer: TapGestureRecognizer() + ..onTap = onClick != null && token != null + ? () => onClick?.call(token) + : null, + text: context.innerHtml, + style: style.merge(TextStyle(backgroundColor: backgroundColor)), + ); + } +} +// Pangea# + class FontColorExtension extends HtmlExtension { static const String colorAttribute = 'color'; static const String mxColorAttribute = 'data-mx-color'; diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 215d57f70..acf3a6341 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -10,6 +10,7 @@ import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/video_player.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/pangea_rich_text.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_token_text.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_toolbar_selection_area.dart'; @@ -117,6 +118,28 @@ class MessageContent extends StatelessWidget { // ), // ); // } + + void onClick(PangeaToken token) { + token = pangeaMessageEvent?.messageDisplayRepresentation + ?.getClosestNonPunctToken(token) ?? + token; + + if (overlayController != null) { + overlayController?.onClickOverlayMessageToken(token); + return; + } + + controller.showToolbar( + pangeaMessageEvent!.event, + pangeaMessageEvent: pangeaMessageEvent, + selectedToken: token, + ); + } + + bool isSelected(PangeaToken token) { + return overlayController!.isTokenSelected(token) || + overlayController!.isTokenHighlighted(token); + } // Pangea# @override @@ -215,6 +238,8 @@ class MessageContent extends StatelessWidget { pangeaMessageEvent: pangeaMessageEvent, nextEvent: nextEvent, prevEvent: prevEvent, + isSelected: overlayController != null ? isSelected : null, + onClick: onClick, // Pangea# ); } @@ -321,28 +346,8 @@ class MessageContent extends StatelessWidget { tokens: pangeaMessageEvent!.messageDisplayRepresentation?.tokens, style: messageTextStyle, - onClick: (token) { - token = pangeaMessageEvent?.messageDisplayRepresentation - ?.getClosestNonPunctToken(token) ?? - token; - - if (overlayController != null) { - overlayController?.onClickOverlayMessageToken(token); - return; - } - - controller.showToolbar( - pangeaMessageEvent!.event, - pangeaMessageEvent: pangeaMessageEvent, - selectedToken: token, - ); - }, - isSelected: overlayController != null - ? (token) { - return overlayController!.isTokenSelected(token) || - overlayController!.isTokenHighlighted(token); - } - : null, + onClick: onClick, + isSelected: overlayController != null ? isSelected : null, ); }