added selecton to html messages

This commit is contained in:
ggurdin 2024-01-24 15:48:03 -05:00
parent ad16c6dfef
commit 2eb4c04d2b
8 changed files with 252 additions and 235 deletions

View file

@ -63,10 +63,7 @@ abstract class AppConfig {
static const bool enableSentry = true;
static const String sentryDns =
'https://8591d0d863b646feb4f3dda7e5dcab38@o256755.ingest.sentry.io/5243143';
//#Pangea
static bool renderHtml = false;
// static bool renderHtml = true;
//Pangea#
static bool renderHtml = true;
static bool hideRedactedEvents = false;
static bool hideUnknownEvents = true;
static bool hideUnimportantStateEvents = true;

View file

@ -1,6 +1,10 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/utils/show_defintion_util.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.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,21 +14,24 @@ import 'package:html/dom.dart' as dom;
import 'package:linkify/linkify.dart';
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';
class HtmlMessage extends StatelessWidget {
final String html;
final Room room;
final Color textColor;
// #Pangea
final ShowDefintionUtil? messageToolbar;
// Pangea#
const HtmlMessage({
super.key,
required this.html,
required this.room,
this.textColor = Colors.black,
// #Pangea
this.messageToolbar,
// Pangea#
});
dom.Node _linkifyHtml(dom.Node element) {
@ -92,84 +99,108 @@ class HtmlMessage extends StatelessWidget {
final element = _linkifyHtml(HtmlParser.parseHTML(renderHtml));
// 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),
// #Pangea
return MouseRegion(
onHover: messageToolbar?.onMouseRegionUpdate,
child: SelectionArea(
onSelectionChanged: (SelectedContent? selection) =>
messageToolbar?.onTextSelection(
selectedContent: selection,
context: context,
),
'a': Style(color: linkColor, textDecorationColor: linkColor),
'h1': Style(
fontSize: FontSize(fontSize * 2),
lineHeight: LineHeight.number(1.5),
fontWeight: FontWeight.w600,
focusNode: messageToolbar?.focusNode,
contextMenuBuilder: (context, state) =>
messageToolbar?.contextMenuOverride(
context: context,
contentSelection: state,
) ??
const SizedBox(),
// Pangea#
child: 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),
CodeExtension(fontSize: fontSize),
MatrixMathExtension(
style: TextStyle(fontSize: fontSize, color: textColor),
),
const TableHtmlExtension(),
SpoilerExtension(textColor: textColor),
const ImageExtension(),
FontColorExtension(),
],
onLinkTap: (url, _, element) => UrlLauncher(
context,
url,
element?.text,
).launchUrl(),
onlyRenderTheseTags: const {
...allowedHtmlTags,
// Needed to make it work properly
'body',
'html',
},
shrinkWrap: true,
),
'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),
CodeExtension(fontSize: fontSize),
MatrixMathExtension(
style: TextStyle(fontSize: fontSize, color: textColor),
),
const TableHtmlExtension(),
SpoilerExtension(textColor: textColor),
const ImageExtension(),
FontColorExtension(),
],
onLinkTap: (url, _, element) => UrlLauncher(
context,
url,
element?.text,
).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

View file

@ -1,3 +1,4 @@
import 'package:fluffychat/pages/chat/events/html_message.dart';
import 'package:fluffychat/pages/chat/events/video_player.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/models/pangea_message_event.dart';
@ -18,7 +19,6 @@ import '../../../utils/platform_infos.dart';
import '../../../utils/url_launcher.dart';
import 'audio_player.dart';
import 'cute_events.dart';
import 'html_message.dart';
import 'image_bubble.dart';
import 'map_bubble.dart';
import 'message_download_content.dart';
@ -182,16 +182,25 @@ class MessageContent extends StatelessWidget {
case MessageTypes.Notice:
case MessageTypes.Emote:
if (AppConfig.renderHtml &&
!event.redacted &&
event.isRichMessage) {
!event.redacted &&
event.isRichMessage
// #Pangea
&&
!pangeaMessageEvent.showRichText
// Pangea#
) {
var html = event.formattedText;
if (event.messageType == MessageTypes.Emote) {
html = '* $html';
}
// #Pangea
messageToolbar?.messageText = html;
// Pangea#
return HtmlMessage(
html: html,
textColor: textColor,
room: event.room,
messageToolbar: messageToolbar,
);
}
// else we fall through to the normal message rendering
@ -281,7 +290,7 @@ class MessageContent extends StatelessWidget {
return MouseRegion(
onHover: messageToolbar?.onMouseRegionUpdate,
child: PangeaRichText(
existingStyle: messageTextStyle,
style: messageTextStyle,
selected: selected,
pangeaMessageEvent: pangeaMessageEvent,
immersionMode: immersionMode,
@ -321,7 +330,12 @@ class MessageContent extends StatelessWidget {
// Pangea#
text: messageText,
focusNode: messageToolbar?.focusNode,
contextMenuBuilder: messageToolbar?.contextMenuOverride,
contextMenuBuilder: (context, state) =>
messageToolbar?.contextMenuOverride(
context: context,
textSelection: state,
) ??
const SizedBox(),
// text: snapshot.data ??
// event.calcLocalizedBodyFallback(
// MatrixLocals(L10n.of(context)!),
@ -341,8 +355,12 @@ class MessageContent extends StatelessWidget {
decorationColor: textColor.withAlpha(150),
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
onSelectionChanged: (selection, cause) => messageToolbar
?.onTextSelection(selection, cause, context),
onSelectionChanged: (selection, cause) =>
messageToolbar?.onTextSelection(
selectedText: selection,
cause: cause,
context: context,
),
);
},
),

View file

@ -10,7 +10,10 @@ import 'package:fluffychat/pangea/utils/bot_name.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
// import markdown.dart
import 'package:html_unescape/html_unescape.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/markdown.dart';
import 'package:matrix/src/utils/space_child.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
@ -858,7 +861,7 @@ extension PangeaRoom on Room {
String? txid,
Event? inReplyTo,
String? editEventId,
bool parseMarkdown = false,
bool parseMarkdown = true,
bool parseCommands = false,
String msgtype = MessageTypes.Text,
String? threadRootEventId,
@ -888,17 +891,19 @@ extension PangeaRoom on Room {
ModelKey.tokensWritten: tokensWritten?.toJson(),
ModelKey.useType: useType?.string,
};
// if (parseMarkdown) {
// final html = markdown(event['body'],
// getEmotePacks: () => getImagePacksFlat(ImagePackUsage.emoticon),
// getMention: getMention);
// // if the decoded html is the same as the body, there is no need in sending a formatted message
// if (HtmlUnescape().convert(html.replaceAll(RegExp(r'<br />\n?'), '\n')) !=
// event['body']) {
// event['format'] = 'org.matrix.custom.html';
// event['formatted_body'] = html;
// }
// }
if (parseMarkdown) {
final html = markdown(
event['body'],
getEmotePacks: () => getImagePacksFlat(ImagePackUsage.emoticon),
getMention: getMention,
);
// if the decoded html is the same as the body, there is no need in sending a formatted message
if (HtmlUnescape().convert(html.replaceAll(RegExp(r'<br />\n?'), '\n')) !=
event['body']) {
event['format'] = 'org.matrix.custom.html';
event['formatted_body'] = html;
}
}
return sendEvent(
event,
txid: txid,

View file

@ -1,4 +1,5 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/constants/pangea_message_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
@ -256,6 +257,10 @@ class PangeaMessageEvent {
//each match is turned into an activity that other students can access
//they're not told the answer but have to find it themselves
//the message has a blank piece which they fill in themselves
// replication of logic from message_content.dart
bool get isHtml =>
AppConfig.renderHtml && !_event.redacted && _event.isRichMessage;
}
class URLFinder {

View file

@ -1,15 +1,15 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/models/pangea_choreo_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/repo/tokens_repo.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/markdown.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../widgets/matrix.dart';
import '../constants/language_keys.dart';
import '../constants/pangea_event_types.dart';
@ -158,4 +158,8 @@ class RepresentationEvent {
return _choreo;
}
String? formatBody() {
return markdown(content.text);
}
}

View file

@ -6,6 +6,7 @@ import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
@ -16,7 +17,7 @@ class ShowDefintionUtil {
final String targetId;
final FocusNode focusNode = FocusNode();
final Room room;
TextSelection? textSelection;
String? textSelection;
bool inCooldown = false;
double? dx;
double? dy;
@ -28,26 +29,21 @@ class ShowDefintionUtil {
required this.messageText,
});
void onTextSelection(
TextSelection selection,
void onTextSelection({
required BuildContext context,
TextSelection? selectedText,
SelectedContent? selectedContent,
SelectionChangedCause? cause,
BuildContext context,
) {
selection.isCollapsed
? clearTextSelection()
: setTextSelection(
selection,
cause,
context,
);
}
}) {
if ((selectedText == null && selectedContent == null) ||
selectedText?.isCollapsed == true) {
clearTextSelection();
return;
}
textSelection = selectedText != null
? selectedText.textInside(messageText)
: selectedContent!.plainText;
void setTextSelection(
TextSelection selection,
SelectionChangedCause? cause,
BuildContext context,
) {
textSelection = selection;
if (BrowserContextMenu.enabled && kIsWeb) {
BrowserContextMenu.disableContextMenu();
}
@ -73,12 +69,11 @@ class ShowDefintionUtil {
}
void showDefinition(BuildContext context) {
final String? fullText = textSelection?.textInside(messageText);
if (fullText == null) return;
if (textSelection == null) return;
OverlayUtil.showPositionedCard(
context: context,
cardToShow: WordDataCard(
word: fullText,
word: textSelection!,
wordLang: langCode,
fullText: messageText,
fullTextLang: langCode,
@ -136,11 +131,21 @@ class ShowDefintionUtil {
dy = event.position.dy;
}
Widget contextMenuOverride(BuildContext context, EditableTextState selection) {
Widget contextMenuOverride({
required BuildContext context,
EditableTextState? textSelection,
SelectableRegionState? contentSelection,
}) {
if (textSelection == null && contentSelection == null) {
return const SizedBox();
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: selection.contextMenuAnchors,
anchors: textSelection?.contextMenuAnchors ??
contentSelection!.contextMenuAnchors,
buttonItems: [
...selection.contextMenuButtonItems,
if (textSelection != null) ...textSelection.contextMenuButtonItems,
if (contentSelection != null)
...contentSelection.contextMenuButtonItems,
ContextMenuButtonItem(
label: L10n.of(context)!.showDefinition,
onPressed: () {

View file

@ -2,6 +2,7 @@ import 'dart:developer';
import 'dart:ui';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/events/html_message.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
@ -14,16 +15,13 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../models/igc_text_data_model.dart';
import '../../models/language_detection_model.dart';
import '../../models/pangea_match_model.dart';
import '../../models/pangea_representation_event.dart';
import '../../utils/bot_style.dart';
import '../../utils/instructions.dart';
class PangeaRichText extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final TextStyle? existingStyle;
final TextStyle? style;
final bool selected;
final LanguageModel? selectedDisplayLang;
final bool immersionMode;
@ -39,7 +37,7 @@ class PangeaRichText extends StatefulWidget {
required this.immersionMode,
required this.definitions,
this.choreographer,
this.existingStyle,
this.style,
this.messageToolbar,
});
@ -50,9 +48,8 @@ class PangeaRichText extends StatefulWidget {
class PangeaRichTextState extends State<PangeaRichText> {
final PangeaController pangeaController = MatrixState.pangeaController;
bool _fetchingRepresentation = false;
bool _fetchingTokens = false;
double get blur => _fetchingRepresentation && widget.immersionMode ? 5 : 0;
List<TextSpan> textSpan = [];
String textSpan = "";
@override
void initState() {
@ -69,7 +66,7 @@ class PangeaRichTextState extends State<PangeaRichText> {
void updateTextSpan() {
setState(() {
textSpan = getTextSpan(context);
widget.messageToolbar?.messageText = textSpan.map((e) => e.text).join();
widget.messageToolbar?.messageText = textSpan;
});
}
@ -94,32 +91,48 @@ class PangeaRichTextState extends State<PangeaRichText> {
);
}
final Widget richText = SelectableText.rich(
onSelectionChanged: (selection, cause) =>
widget.messageToolbar?.onTextSelection(selection, cause, context),
focusNode: widget.messageToolbar?.focusNode,
contextMenuBuilder: widget.messageToolbar?.contextMenuOverride,
TextSpan(
children: [
...textSpan,
if (widget.selected && (_fetchingRepresentation || _fetchingTokens))
// if (widget.selected)
const WidgetSpan(
child: Padding(
padding: EdgeInsets.only(left: 5.0),
child: SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(
strokeWidth: 2.0,
color: AppConfig.secondaryColor,
),
),
),
final Widget richText = widget.pangeaMessageEvent.isHtml
? HtmlMessage(
html: textSpan,
room: widget.pangeaMessageEvent.room,
textColor: widget.style?.color ?? Colors.black,
messageToolbar: widget.messageToolbar,
)
: SelectableText.rich(
onSelectionChanged: (selection, cause) =>
widget.messageToolbar?.onTextSelection(
selectedText: selection,
cause: cause,
context: context,
),
],
),
);
focusNode: widget.messageToolbar?.focusNode,
contextMenuBuilder: (context, state) =>
widget.messageToolbar?.contextMenuOverride(
context: context,
textSelection: state,
) ??
const SizedBox(),
TextSpan(
text: textSpan,
style: widget.style,
children: [
if (widget.selected && (_fetchingRepresentation))
const WidgetSpan(
child: Padding(
padding: EdgeInsets.only(left: 5.0),
child: SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(
strokeWidth: 2.0,
color: AppConfig.secondaryColor,
),
),
),
),
],
),
);
return blur > 0
? ImageFiltered(
@ -129,17 +142,17 @@ class PangeaRichTextState extends State<PangeaRichText> {
: richText;
}
List<TextSpan> getTextSpan(BuildContext context) {
String getTextSpan(BuildContext context) {
final String? displayLangCode =
widget.selected ? widget.selectedDisplayLang?.langCode : userL2LangCode;
if (displayLangCode == null || !widget.immersionMode) {
return simpleText(widget.pangeaMessageEvent.body);
return widget.pangeaMessageEvent.body;
}
if (widget.pangeaMessageEvent.eventId.contains("webdebug")) {
debugger(when: kDebugMode);
return simpleText(widget.pangeaMessageEvent.body);
return widget.pangeaMessageEvent.body;
}
final RepresentationEvent? repEvent =
@ -158,7 +171,7 @@ class PangeaRichTextState extends State<PangeaRichText> {
)
.onError((error, stackTrace) => ErrorHandler.logError())
.whenComplete(() => setState(() => _fetchingRepresentation = false));
return simpleText(widget.pangeaMessageEvent.body);
return widget.pangeaMessageEvent.body;
}
if (repEvent.event?.eventId.contains("web") ?? false) {
@ -171,74 +184,13 @@ class PangeaRichTextState extends State<PangeaRichText> {
"representationByLanguageGlobal returned RepEvent with event ID containing 'web' - ${repEvent.event?.eventId}",
),
);
// debugger(when: kDebugMode);
return textWithBotStyle(repEvent, context);
}
if (!widget.selected ||
displayLangCode != userL2LangCode ||
!widget.definitions) {
return textWithBotStyle(repEvent, context);
}
if (repEvent.tokens == null) {
setState(() => _fetchingTokens = true);
repEvent
.tokensGlobal(context)
.onError((error, stackTrace) => ErrorHandler.logError())
.whenComplete(() => setState(() => _fetchingTokens = false));
return textWithBotStyle(repEvent, context);
}
return IGCTextData(
originalInput: repEvent.text,
fullTextCorrection: repEvent.text,
matches: [],
detections: [LanguageDetection(langCode: displayLangCode)],
tokens: repEvent.tokens!,
enableIT: true,
enableIGC: true,
userL2: userL2LangCode ?? LanguageKeys.unknownLanguage,
userL1: userL1LangCode ?? LanguageKeys.unknownLanguage,
).constructTokenSpan(
context: context,
defaultStyle: textStyle(repEvent, context),
handleClick: false,
spanCardModel: null,
transformTargetId: widget.pangeaMessageEvent.eventId,
room: widget.pangeaMessageEvent.room,
);
return widget.pangeaMessageEvent.isHtml
? repEvent.formatBody() ?? repEvent.text
: repEvent.text;
}
List<TextSpan> simpleText(String text) => [
TextSpan(
text: text,
style: widget.existingStyle,
),
];
List<TextSpan> textWithBotStyle(
RepresentationEvent repEvent,
BuildContext context,
) =>
[
TextSpan(
text: repEvent.text,
style: textStyle(repEvent, context),
),
];
TextStyle? textStyle(RepresentationEvent repEvent, BuildContext context) =>
// !repEvent.botAuthored
true
? widget.existingStyle
: BotStyle.text(
context,
existingStyle: widget.existingStyle,
setColor: false,
);
bool get areLanguagesSet =>
userL2LangCode != null && userL2LangCode != LanguageKeys.unknownLanguage;