forked from Nexus/nexus
we got quotes 🔥
This commit is contained in:
parent
51d6e73c24
commit
11c03733cf
13 changed files with 159 additions and 124 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
8
lib/helpers/extensions/color_hex.dart
Normal file
8
lib/helpers/extensions/color_hex.dart
Normal 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")}";
|
||||
}
|
||||
}
|
||||
100
lib/helpers/extensions/event_to_message.dart
Normal file
100
lib/helpers/extensions/event_to_message.dart
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
lib/helpers/extensions/list_to_messages.dart
Normal file
15
lib/helpers/extensions/list_to_messages.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
14
lib/helpers/extensions/scheme_to_theme.dart
Normal file
14
lib/helpers/extensions/scheme_to_theme.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
54
lib/widgets/chat_page/html/code_block.dart
Normal file
54
lib/widgets/chat_page/html/code_block.dart
Normal 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"),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
93
lib/widgets/chat_page/html/html.dart
Normal file
93
lib/widgets/chat_page/html/html.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
29
lib/widgets/chat_page/html/spoiler_text.dart
Normal file
29
lib/widgets/chat_page/html/spoiler_text.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/widgets/chat_page/quoted.dart
Normal file
16
lib/widgets/chat_page/quoted.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue