diff --git a/.vscode/settings.json b/.vscode/settings.json index b1f3bab..e2e527c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["Displayname"] + "cSpell.words": ["Appbar", "Displayname"] } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index fc9218f..7af35c3 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -1,7 +1,7 @@ import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/current_room_controller.dart"; -import "package:nexus/helpers/extensions/to_message.dart"; +import "package:nexus/helpers/extensions/event_to_message.dart"; class MessageController extends AsyncNotifier { final String id; diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index f7d35a5..aed2a46 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -4,8 +4,8 @@ import "package:flutter_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:matrix/matrix.dart"; import "package:nexus/controllers/events_controller.dart"; -import "package:nexus/helpers/extensions/to_message.dart"; -import "package:nexus/helpers/extensions/to_messages.dart"; +import "package:nexus/helpers/extensions/event_to_message.dart"; +import "package:nexus/helpers/extensions/list_to_messages.dart"; class RoomChatController extends AsyncNotifier { final Room room; diff --git a/lib/helpers/extensions/color_hex.dart b/lib/helpers/extensions/color_hex.dart new file mode 100644 index 0000000..3f04629 --- /dev/null +++ b/lib/helpers/extensions/color_hex.dart @@ -0,0 +1,8 @@ +import "package:flutter/widgets.dart"; + +extension ColorHex on Color { + String get hex { + final rgb = toARGB32() & 0x00FFFFFF; + return "#${rgb.toRadixString(16).padLeft(6, "0")}"; + } +} diff --git a/lib/helpers/extensions/to_message.dart b/lib/helpers/extensions/event_to_message.dart similarity index 98% rename from lib/helpers/extensions/to_message.dart rename to lib/helpers/extensions/event_to_message.dart index f816e6a..2353c91 100644 --- a/lib/helpers/extensions/to_message.dart +++ b/lib/helpers/extensions/event_to_message.dart @@ -1,7 +1,7 @@ import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:matrix/matrix.dart"; -extension ToMessage on Event { +extension EventToMessage on Event { Future toMessage({bool mustBeText = false}) async { final replyId = relationshipType == RelationshipTypes.reply ? relationshipEventId diff --git a/lib/helpers/extensions/to_messages.dart b/lib/helpers/extensions/list_to_messages.dart similarity index 77% rename from lib/helpers/extensions/to_messages.dart rename to lib/helpers/extensions/list_to_messages.dart index 81e1618..d7f89c4 100644 --- a/lib/helpers/extensions/to_messages.dart +++ b/lib/helpers/extensions/list_to_messages.dart @@ -1,8 +1,8 @@ import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:matrix/matrix.dart"; -import "package:nexus/helpers/extensions/to_message.dart"; +import "package:nexus/helpers/extensions/event_to_message.dart"; -extension ToMessages on List { +extension ListToMessages on List { Future> toMessages(Room room) async { final messages = await Future.wait( map((event) => Event.fromMatrixEvent(event, room).toMessage()), diff --git a/lib/helpers/extensions/to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart similarity index 90% rename from lib/helpers/extensions/to_theme.dart rename to lib/helpers/extensions/scheme_to_theme.dart index 61edd69..e238cf9 100644 --- a/lib/helpers/extensions/to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -1,6 +1,6 @@ import "package:flutter/material.dart"; -extension ToTheme on ColorScheme { +extension SchemeToTheme on ColorScheme { ThemeData get theme => ThemeData.from(colorScheme: this).copyWith( cardTheme: CardThemeData(color: primaryContainer), appBarTheme: AppBarTheme( diff --git a/lib/main.dart b/lib/main.dart index c6daaf1..f233848 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/to_theme.dart"; +import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/pages/chat_page.dart"; import "package:nexus/pages/login_page.dart"; import "package:window_manager/window_manager.dart"; diff --git a/lib/widgets/chat_page/code_block.dart b/lib/widgets/chat_page/html/code_block.dart similarity index 90% rename from lib/widgets/chat_page/code_block.dart rename to lib/widgets/chat_page/html/code_block.dart index f407f75..fe5b492 100644 --- a/lib/widgets/chat_page/code_block.dart +++ b/lib/widgets/chat_page/html/code_block.dart @@ -39,7 +39,10 @@ class CodeBlock extends StatelessWidget { child: Container( constraints: BoxConstraints(minWidth: 250), padding: EdgeInsets.all(8), - child: SelectableText(code), + child: SelectableText( + code, + style: TextStyle(fontFamily: "monospace"), + ), ), ), ], diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart new file mode 100644 index 0000000..505a52f --- /dev/null +++ b/lib/widgets/chat_page/html/html.dart @@ -0,0 +1,93 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/widgets/chat_page/html/spoiler_text.dart"; +import "package:nexus/widgets/chat_page/html/code_block.dart"; +import "package:nexus/widgets/chat_page/quoted.dart"; + +class Html extends ConsumerWidget { + final String html; + const Html(this.html, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( + html, + customWidgetBuilder: (element) { + if (element.attributes.keys.contains("data-mx-spoiler")) { + return SpoilerText(text: element.text); + } + return switch (element.localName) { + "mx-reply" => SizedBox.shrink(), + + "code" => CodeBlock( + element.text, + lang: element.className.replaceAll("language-", ""), + ), + + "blockquote" => Quoted(Html(element.innerHtml)), + + ("del" || + "h1" || + "h2" || + "h3" || + "h4" || + "h5" || + "h6" || + "p" || + "a" || + "ul" || + "ol" || + "sup" || + "sub" || + "li" || + "b" || + "i" || + "u" || + "strong" || + "em" || + "s" || + "code" || + "hr" || + "br" || + "div" || + "table" || + "thead" || + "tbody" || + "tr" || + "th" || + "td" || + "caption" || + "pre" || + "span" || + "img" || + "details" || + "summary") => + null, + + _ => SizedBox.shrink(), + }; + }, + customStylesBuilder: (element) => { + "width": "auto", + ...Map.fromEntries( + element.attributes + .mapTo?>( + (key, value) => switch (key) { + "data-mx-color" => MapEntry("color", value), + + "data-mx-bg-color" => MapEntry("background-color", value), + + "edited" => MapEntry("display", "block"), + + _ => null, + }, + ) + .nonNulls, + ), + }, + onTapUrl: (url) => + ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)), + ); +} diff --git a/lib/widgets/chat_page/spoiler_text.dart b/lib/widgets/chat_page/html/spoiler_text.dart similarity index 100% rename from lib/widgets/chat_page/spoiler_text.dart rename to lib/widgets/chat_page/html/spoiler_text.dart diff --git a/lib/widgets/chat_page/quoted.dart b/lib/widgets/chat_page/quoted.dart new file mode 100644 index 0000000..6640118 --- /dev/null +++ b/lib/widgets/chat_page/quoted.dart @@ -0,0 +1,16 @@ +import "package:flutter/material.dart"; + +class Quoted extends StatelessWidget { + final Widget child; + const Quoted(this.child, {super.key}); + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(width: 4, color: Theme.of(context).dividerColor), + ), + ), + child: Padding(padding: EdgeInsets.only(left: 8), child: child), + ); +} diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 0f815c6..9170e9c 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -1,4 +1,3 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; @@ -15,14 +14,11 @@ import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; -import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/widgets/chat_page/chat_box.dart"; -import "package:nexus/widgets/chat_page/code_block.dart"; +import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/member_list.dart"; import "package:nexus/widgets/chat_page/room_appbar.dart"; -import "package:nexus/widgets/chat_page/spoiler_text.dart"; import "package:nexus/widgets/chat_page/top_widget.dart"; -import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/widgets/loading.dart"; @@ -175,8 +171,7 @@ class RoomChat extends HookConsumerWidget { loadMoreBuilder: (_) => Loading(), chatAnimatedListBuilder: (_, itemBuilder) => ChatAnimatedList( - itemBuilder: - itemBuilder, // TODO: Load earlier + itemBuilder: itemBuilder, onEndReached: notifier.loadOlder, onStartReached: () async { notifier.markRead(); @@ -195,7 +190,7 @@ class RoomChat extends HookConsumerWidget { required bool isSentByMe, MessageGroupStatus? groupStatus, }) => FlyerChatTextMessage( - customWidget: HtmlWidget( + customWidget: Html( message.metadata?["formatted"] .replaceAllMapped( RegExp( @@ -208,76 +203,6 @@ class RoomChat extends HookConsumerWidget { ((message.editedAt != null) ? "(edited)" : ""), - customWidgetBuilder: (element) { - if (element.localName == "mx-reply") { - return SizedBox.shrink(); - } - if (element.localName == "code") { - if (element.parent?.localName == - "pre") { - return CodeBlock( - element.text, - lang: element.className - .replaceAll("language-", ""), - ); - } - } - if (element.localName == "img") { - final src = Uri.tryParse( - element.attributes["src"] ?? "", - ); - if (src?.scheme != "mxc") { - return SizedBox.shrink(); - } - - // TODO: Should do something like: - // return Image.network( - // src!.getThumbnailUri( - // room.roomData.client, - // ), - // ); - - return SizedBox.shrink(); - } - if (element.attributes.keys.contains( - "data-mx-spoiler", - )) { - return SpoilerText( - text: element.text, - ); - } - return null; - }, - customStylesBuilder: (element) => { - "width": "auto", - ...Map.fromEntries( - element.attributes - .mapTo?>( - (key, value) => switch (key) { - "data-mx-color" => MapEntry( - "color", - value, - ), - - "data-mx-bg-color" => - MapEntry( - "background-color", - value, - ), - - "edited" => MapEntry( - "display", - "block", - ), - _ => null, - }, - ) - .nonNulls, - ), - }, - onTapUrl: (url) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(url)), ), topWidget: TopWidget( message, diff --git a/lib/widgets/chat_page/top_widget.dart b/lib/widgets/chat_page/top_widget.dart index dda5a17..0f2283f 100644 --- a/lib/widgets/chat_page/top_widget.dart +++ b/lib/widgets/chat_page/top_widget.dart @@ -5,6 +5,7 @@ import "package:flutter_chat_ui/flutter_chat_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/message_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/widgets/chat_page/quoted.dart"; class TopWidget extends ConsumerWidget { final Message message; @@ -54,45 +55,34 @@ class TopWidget extends ConsumerWidget { return InkWell( // TODO: Scroll to original message onTap: () => showAboutDialog(context: context), - child: Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide( - width: 4, - color: Theme.of(context).dividerColor, + child: Quoted( + Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Avatar( + userId: replyMessage.authorId, + headers: headers, + size: 16, ), - ), - ), - child: Padding( - padding: EdgeInsets.only(left: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - Avatar( - userId: replyMessage.authorId, - headers: headers, - size: 16, + Flexible( + child: Text( + replyMessage.metadata?["displayName"] ?? + replyMessage.authorId, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, ), - Flexible( - child: Text( - replyMessage.metadata?["displayName"] ?? - replyMessage.authorId, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith(fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - ), + ), + Flexible( + child: Text( + replyText, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium, + maxLines: 1, ), - Flexible( - child: Text( - replyText, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium, - maxLines: 1, - ), - ), - ], - ), + ), + ], ), ), );