we got quotes 🔥

This commit is contained in:
Henry Hiles 2025-12-03 12:16:59 -05:00
commit 11c03733cf
No known key found for this signature in database
14 changed files with 159 additions and 124 deletions

View file

@ -1,3 +1,3 @@
{ {
"cSpell.words": ["Displayname"] "cSpell.words": ["Appbar", "Displayname"]
} }

View file

@ -1,7 +1,7 @@
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/current_room_controller.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<TextMessage?> { class MessageController extends AsyncNotifier<TextMessage?> {
final String id; final String id;

View file

@ -4,8 +4,8 @@ import "package:flutter_chat_core/flutter_chat_core.dart" as chat;
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart"; import "package:matrix/matrix.dart";
import "package:nexus/controllers/events_controller.dart"; import "package:nexus/controllers/events_controller.dart";
import "package:nexus/helpers/extensions/to_message.dart"; import "package:nexus/helpers/extensions/event_to_message.dart";
import "package:nexus/helpers/extensions/to_messages.dart"; import "package:nexus/helpers/extensions/list_to_messages.dart";
class RoomChatController extends AsyncNotifier<ChatController> { class RoomChatController extends AsyncNotifier<ChatController> {
final Room room; final Room room;

View file

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

View file

@ -1,7 +1,7 @@
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:matrix/matrix.dart"; import "package:matrix/matrix.dart";
extension ToMessage on Event { extension EventToMessage on Event {
Future<Message?> toMessage({bool mustBeText = false}) async { Future<Message?> toMessage({bool mustBeText = false}) async {
final replyId = relationshipType == RelationshipTypes.reply final replyId = relationshipType == RelationshipTypes.reply
? relationshipEventId ? relationshipEventId

View file

@ -1,8 +1,8 @@
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:matrix/matrix.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<MatrixEvent> { extension ListToMessages on List<MatrixEvent> {
Future<List<Message>> toMessages(Room room) async { Future<List<Message>> toMessages(Room room) async {
final messages = await Future.wait( final messages = await Future.wait(
map((event) => Event.fromMatrixEvent(event, room).toMessage()), map((event) => Event.fromMatrixEvent(event, room).toMessage()),

View file

@ -1,6 +1,6 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
extension ToTheme on ColorScheme { extension SchemeToTheme on ColorScheme {
ThemeData get theme => ThemeData.from(colorScheme: this).copyWith( ThemeData get theme => ThemeData.from(colorScheme: this).copyWith(
cardTheme: CardThemeData(color: primaryContainer), cardTheme: CardThemeData(color: primaryContainer),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(

View file

@ -1,7 +1,7 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/extensions/better_when.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/chat_page.dart";
import "package:nexus/pages/login_page.dart"; import "package:nexus/pages/login_page.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";

View file

@ -39,7 +39,10 @@ class CodeBlock extends StatelessWidget {
child: Container( child: Container(
constraints: BoxConstraints(minWidth: 250), constraints: BoxConstraints(minWidth: 250),
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
child: SelectableText(code), child: SelectableText(
code,
style: TextStyle(fontFamily: "monospace"),
),
), ),
), ),
], ],

View file

@ -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<MapEntry<String, String>?>(
(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)),
);
}

View file

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

View file

@ -1,4 +1,3 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.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/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/show_context_menu.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/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/member_list.dart";
import "package:nexus/widgets/chat_page/room_appbar.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: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/form_text_input.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -175,8 +171,7 @@ class RoomChat extends HookConsumerWidget {
loadMoreBuilder: (_) => Loading(), loadMoreBuilder: (_) => Loading(),
chatAnimatedListBuilder: (_, itemBuilder) => chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList( ChatAnimatedList(
itemBuilder: itemBuilder: itemBuilder,
itemBuilder, // TODO: Load earlier
onEndReached: notifier.loadOlder, onEndReached: notifier.loadOlder,
onStartReached: () async { onStartReached: () async {
notifier.markRead(); notifier.markRead();
@ -195,7 +190,7 @@ class RoomChat extends HookConsumerWidget {
required bool isSentByMe, required bool isSentByMe,
MessageGroupStatus? groupStatus, MessageGroupStatus? groupStatus,
}) => FlyerChatTextMessage( }) => FlyerChatTextMessage(
customWidget: HtmlWidget( customWidget: Html(
message.metadata?["formatted"] message.metadata?["formatted"]
.replaceAllMapped( .replaceAllMapped(
RegExp( RegExp(
@ -208,76 +203,6 @@ class RoomChat extends HookConsumerWidget {
((message.editedAt != null) ((message.editedAt != null)
? "<sub edited>(edited)</sub>" ? "<sub edited>(edited)</sub>"
: ""), : ""),
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<MapEntry<String, String>?>(
(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( topWidget: TopWidget(
message, message,

View file

@ -5,6 +5,7 @@ import "package:flutter_chat_ui/flutter_chat_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/message_controller.dart"; import "package:nexus/controllers/message_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/widgets/chat_page/quoted.dart";
class TopWidget extends ConsumerWidget { class TopWidget extends ConsumerWidget {
final Message message; final Message message;
@ -54,45 +55,34 @@ class TopWidget extends ConsumerWidget {
return InkWell( return InkWell(
// TODO: Scroll to original message // TODO: Scroll to original message
onTap: () => showAboutDialog(context: context), onTap: () => showAboutDialog(context: context),
child: Container( child: Quoted(
decoration: BoxDecoration( Row(
border: Border( mainAxisSize: MainAxisSize.min,
left: BorderSide( spacing: 8,
width: 4, children: [
color: Theme.of(context).dividerColor, Avatar(
userId: replyMessage.authorId,
headers: headers,
size: 16,
), ),
), Flexible(
), child: Text(
child: Padding( replyMessage.metadata?["displayName"] ??
padding: EdgeInsets.only(left: 8), replyMessage.authorId,
child: Row( style: Theme.of(context).textTheme.labelMedium
mainAxisSize: MainAxisSize.min, ?.copyWith(fontWeight: FontWeight.bold),
spacing: 8, overflow: TextOverflow.ellipsis,
children: [
Avatar(
userId: replyMessage.authorId,
headers: headers,
size: 16,
), ),
Flexible( ),
child: Text( Flexible(
replyMessage.metadata?["displayName"] ?? child: Text(
replyMessage.authorId, replyText,
style: Theme.of(context).textTheme.labelMedium overflow: TextOverflow.ellipsis,
?.copyWith(fontWeight: FontWeight.bold), style: Theme.of(context).textTheme.labelMedium,
overflow: TextOverflow.ellipsis, maxLines: 1,
),
), ),
Flexible( ),
child: Text( ],
replyText,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium,
maxLines: 1,
),
),
],
),
), ),
), ),
); );