we got quotes 🔥

This commit is contained in:
Henry Hiles 2025-12-03 12:16:59 -05:00
commit 11c03733cf
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
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_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<TextMessage?> {
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: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<ChatController> {
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

@ -0,0 +1,100 @@
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:matrix/matrix.dart";
extension EventToMessage on Event {
Future<Message?> toMessage({bool mustBeText = false}) async {
final replyId = relationshipType == RelationshipTypes.reply
? relationshipEventId
: null;
final sender = (await fetchSenderUser()) ?? senderFromMemoryOrFallback;
final newContent = content["m.new_content"] as Map<String, Object?>?;
final metadata = {
"formatted":
newContent?["formatted_body"] ??
newContent?["body"] ??
content["formatted_body"] ??
this.body,
"eventType": type,
"displayName": sender.displayName ?? sender.id,
"txnId": transactionId,
};
final editedAt = relationshipType == RelationshipTypes.edit
? originServerTs
: null;
final body = newContent?["body"] as String? ?? this.body;
final eventId = editedAt == null
? this.eventId
: relationshipEventId ?? this.eventId;
if (redacted) return null;
final asText =
Message.text(
metadata: metadata,
id: eventId,
authorId: senderId,
text: body,
replyToMessageId: replyId,
deliveredAt: originServerTs,
editedAt: editedAt,
)
as TextMessage;
if (mustBeText) return asText;
return switch (type) {
EventTypes.Encrypted => asText.copyWith(
text: "Unable to decrypt message.",
metadata: {"formatted": "Unable to decrypt message.", ...metadata},
),
EventTypes.Message => switch (messageType) {
MessageTypes.Image => Message.image(
metadata: metadata,
id: eventId,
authorId: senderId,
text: text,
source: (await getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: originServerTs,
),
MessageTypes.Audio => Message.audio(
metadata: metadata,
id: eventId,
authorId: senderId,
text: text,
replyToMessageId: replyId,
source: (await getAttachmentUri()).toString(),
deliveredAt: originServerTs,
duration: Duration(hours: 1),
),
MessageTypes.File => Message.file(
name: content["filename"].toString(),
metadata: metadata,
id: eventId,
authorId: senderId,
source: (await getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: originServerTs,
),
_ => asText,
},
EventTypes.RoomMember => Message.system(
metadata: metadata,
id: eventId,
authorId: senderId,
text:
"${senderFromMemoryOrFallback.calcDisplayname()} joined the room.",
),
EventTypes.Redaction => null,
EventTypes.Reaction => null,
_ => Message.unsupported(
metadata: metadata,
id: eventId,
authorId: senderId,
replyToMessageId: replyId,
),
};
}
}

View file

@ -0,0 +1,15 @@
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:matrix/matrix.dart";
import "package:nexus/helpers/extensions/event_to_message.dart";
extension ListToMessages on List<MatrixEvent> {
Future<List<Message>> toMessages(Room room) async {
final messages = await Future.wait(
map((event) => Event.fromMatrixEvent(event, room).toMessage()),
);
return {
for (var msg in messages.nonNulls.toList().reversed.toList()) msg.id: msg,
}.values.toList();
}
}

View file

@ -0,0 +1,14 @@
import "package:flutter/material.dart";
extension SchemeToTheme on ColorScheme {
ThemeData get theme => ThemeData.from(colorScheme: this).copyWith(
cardTheme: CardThemeData(color: primaryContainer),
appBarTheme: AppBarTheme(
titleSpacing: 0,
backgroundColor: surfaceContainerLow,
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
);
}

View file

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

View file

@ -0,0 +1,54 @@
import "dart:math";
import "package:flutter/material.dart";
class CodeBlock extends StatelessWidget {
final String code;
final String lang;
const CodeBlock(this.code, {required this.lang, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(16)),
child: ColoredBox(
color: theme.colorScheme.surfaceContainerHighest,
child: IntrinsicWidth(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(
lang.substring(0, min(lang.length, 15)),
style: TextStyle(fontFamily: "monospace"),
),
),
TextButton.icon(
onPressed: () {},
icon: Icon(Icons.copy),
label: Text("Copy"),
),
],
),
ColoredBox(
color: theme.colorScheme.surfaceContainerHigh,
child: Container(
constraints: BoxConstraints(minWidth: 250),
padding: EdgeInsets.all(8),
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,29 @@
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
class SpoilerText extends HookWidget {
final String text;
const SpoilerText({super.key, required this.text});
@override
Widget build(BuildContext context) {
final revealed = useState(false);
return InkWell(
onTap: () => revealed.value = !revealed.value,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: revealed.value ? Colors.transparent : Colors.blueGrey,
borderRadius: BorderRadius.circular(4),
),
child: Text(
text,
style: TextStyle(color: revealed.value ? null : Colors.transparent),
),
),
);
}
}

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/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)
? "<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(
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: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,18 +55,8 @@ 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: Padding(
padding: EdgeInsets.only(left: 8),
child: Row(
child: Quoted(
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
@ -94,7 +85,6 @@ class TopWidget extends ConsumerWidget {
],
),
),
),
);
},
),