feat: add clicking for tokens in HTML messages (#1510)

pull/1593/head
ggurdin 10 months ago committed by GitHub
parent 383fe50c7f
commit f59b31ce9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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<PangeaToken>? 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<PangeaToken> 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('</token>');
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<token offset=\"${token.text.offset}\" length=\"${token.text.length}\">$tokenText</token>$after";
}
final newElement = dom.Element.html('<span>$tokenizedText</span>');
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<String> 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';

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

Loading…
Cancel
Save