continue layout changes

This commit is contained in:
Henry Hiles 2026-03-13 14:39:43 -04:00
commit dd7b88c994
No known key found for this signature in database
4 changed files with 265 additions and 293 deletions

View file

@ -46,6 +46,12 @@ class MessageController extends AsyncNotifier<Message?> {
final content = (event.decrypted ?? event.content);
final type = (config.event.decryptedType ?? config.event.type);
final newContent = content["m.new_content"] as Map?;
final homeserver = ref.read(ClientStateController.provider)?.homeserverUrl;
final source = homeserver == null || content["url"] == null
? "null"
: Uri.parse(content["url"]).mxcToHttps(homeserver).toString();
final metadata = {
"body": config.event.redactedBy == null
? (newContent?["body"] ?? content["body"] ?? "")
@ -72,6 +78,16 @@ class MessageController extends AsyncNotifier<Message?> {
? author?.content["displayname"]
: event.authorId.substring(1).split(":")[0],
"txnId": config.event.transactionId,
"image": content["msgtype"] == "m.image"
? Message.image(
id: "${config.event.eventId}-image",
authorId: event.authorId,
source: source,
replyToMessageId: replyId,
deliveredAt: config.event.timestamp,
blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"],
)
: null,
};
if (!ref.mounted) return null;
@ -106,11 +122,6 @@ class MessageController extends AsyncNotifier<Message?> {
)
as TextMessage;
final homeserver = ref.read(ClientStateController.provider)?.homeserverUrl;
final source = homeserver == null || content["url"] == null
? "null"
: Uri.parse(content["url"]).mxcToHttps(homeserver).toString();
return switch (type) {
"m.room.encrypted" => asText.copyWith(
text: "Unable to decrypt message.",
@ -127,20 +138,6 @@ class MessageController extends AsyncNotifier<Message?> {
// authorId: senderId,
// ),
("m.sticker" || "m.room.message") => switch (content["msgtype"]) {
(null || "m.image") => Message.image(
id: config.event.eventId,
metadata: metadata,
authorId: event.authorId,
text:
newContent?["formatted_body"] ??
newContent?["body"] ??
content["formatted_body"] ??
content["body"],
source: source,
replyToMessageId: replyId,
deliveredAt: config.event.timestamp,
blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"],
),
"m.audio" || "m.file" => Message.file(
name: content["filename"].toString(),
size: content["info"]["size"],

View file

@ -0,0 +1,39 @@
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MessageWrapper extends StatelessWidget {
final Message message;
final Widget child;
final MessageGroupStatus? groupStatus;
const MessageWrapper(this.message, this.child, this.groupStatus, {super.key});
@override
Widget build(BuildContext context) => Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
groupStatus?.isFirst != false
? AvatarOrHash(
Uri.parse(message.metadata?["avatarUrl"] ?? ""),
height: 40,
message.metadata?["displayName"] ?? "",
)
: SizedBox(width: 40),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (groupStatus?.isFirst != false)
Text(
message.metadata?["displayName"] ?? message.authorId,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
child,
],
),
],
);
}

View file

@ -1,5 +1,4 @@
import "dart:math";
import "package:cross_cache/cross_cache.dart";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
@ -21,10 +20,10 @@ import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/report_request.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/chat_page/chat_box.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/message_wrapper.dart";
import "package:nexus/widgets/chat_page/room_appbar.dart";
import "package:nexus/widgets/chat_page/top_widget.dart";
import "package:nexus/widgets/form_text_input.dart";
@ -52,37 +51,6 @@ class RoomChat extends HookConsumerWidget {
final theme = Theme.of(context);
final danger = theme.colorScheme.error;
Widget getTextWidget(TextMessage message) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Html(
textStyle: message.metadata?["big"] == true
? TextStyle(fontSize: 32)
: null,
message.text
.replaceAllMapped(
RegExp(
"(<a\\b[^>]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)",
caseSensitive: false,
),
(m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) != null) {
return m.group(1)!;
}
// Otherwise, wrap the bare URL
final url = m.group(2)!;
return "<a href=\"$url\">$url</a>";
},
)
.replaceAll("\n", "<br class=\"fake-break\"/>"),
),
if (message.editedAt != null)
Text("(edited)", style: theme.textTheme.labelSmall),
],
);
if (room == null || userId == null || room.metadata?.id == null) {
return Center(
child: Text(
@ -268,47 +236,6 @@ class RoomChat extends HookConsumerWidget {
globalPosition: details.globalPosition,
children: getMessageOptions(message),
),
onMessageTap:
(
context,
message, {
required details,
required index,
}) {
if (message is ImageMessage) {
showDialog(
context: context,
builder: (_) => LayoutBuilder(
builder: (context, constraints) => Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.all(
constraints.maxWidth / 100,
),
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: min(
constraints.maxWidth,
1000,
),
),
child: InteractiveViewer(
child: Image(
fit: BoxFit.contain,
image: CachedNetworkImage(
message.source,
ref.watch(
CrossCacheController.provider,
),
headers: ref.headers,
),
),
),
),
),
),
);
}
},
builders: Builders(
loadMoreBuilder: (_) => Loading(),
chatAnimatedListBuilder: (_, itemBuilder) =>
@ -432,48 +359,161 @@ class RoomChat extends HookConsumerWidget {
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
groupStatus?.isFirst != false
? AvatarOrHash(
Uri.parse(
message.metadata?["avatarUrl"] ??
"",
),
height: 40,
message.metadata?["displayName"] ??
"",
)
: SizedBox(width: 40),
}) {
final image =
message.metadata?["image"]
as ImageMessage?;
return MessageWrapper(
message,
Column(
spacing: 4,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
if (groupStatus?.isFirst != false)
Text(
message.metadata?["displayName"] ??
message.authorId,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
FlyerChatTextMessage(
showStatus: false,
customWidget: getTextWidget(message),
message: message,
showTime: true,
showStatus: false,
customWidget: Column(
spacing: 4,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Html(
textStyle:
message.metadata?["big"] ==
true
? TextStyle(
fontSize: 32,
)
: null,
message.text
.replaceAllMapped(
RegExp(
"(<a\\b[^>]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)",
caseSensitive:
false,
),
(m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) !=
null) {
return m.group(
1,
)!;
}
// Otherwise, wrap the bare URL
final url = m.group(
2,
)!;
return "<a href=\"$url\">$url</a>";
},
)
.replaceAll(
"\n",
"<br class=\"fake-break\"/>",
),
),
if (message.editedAt != null)
Text(
"(edited)",
style: theme
.textTheme
.labelSmall,
),
],
),
if (image != null)
InkWell(
onTap: () => showDialog(
context: context,
builder: (_) => LayoutBuilder(
builder: (context, constraints) => Dialog(
backgroundColor:
Colors.transparent,
insetPadding:
EdgeInsets.all(
constraints
.maxWidth /
100,
),
child: ConstrainedBox(
constraints:
BoxConstraints(
minWidth: min(
constraints
.maxWidth,
1000,
),
),
child: InteractiveViewer(
child: Image(
fit: BoxFit
.contain,
image: CachedNetworkImage(
image.source,
ref.watch(
CrossCacheController
.provider,
),
headers:
ref.headers,
),
),
),
),
),
),
),
child: FlyerChatImageMessage(
customImageProvider:
CachedNetworkImage(
image.source,
ref.watch(
CrossCacheController
.provider,
),
headers: ref.headers,
),
errorBuilder:
(
context,
error,
stackTrace,
) => Center(
child: Text(
"Image Failed to Load",
style: TextStyle(
color: Theme.of(
context,
).colorScheme.error,
),
),
),
message: image,
index: index,
),
),
],
),
topWidget: TopWidget(
message,
groupStatus: groupStatus,
onTapReply:
notifier.scrollToMessage,
),
message: message,
index: index,
),
],
),
],
),
groupStatus,
);
},
linkPreviewBuilder: (_, message, isSentByMe) =>
LinkPreview(
text: message.text,
@ -493,80 +533,6 @@ class RoomChat extends HookConsumerWidget {
),
),
),
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) {
final textMessage =
message.text?.isNotEmpty == true
? TextMessage(
id: "${message.id}-text",
authorId: message.authorId,
text: message.text!,
)
: null;
return Column(
spacing: 4,
crossAxisAlignment: isSentByMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
SizedBox(height: 12),
if (textMessage != null)
FlyerChatTextMessage(
customWidget: getTextWidget(
textMessage,
),
topWidget: TopWidget(
message,
groupStatus: groupStatus,
onTapReply:
notifier.scrollToMessage,
alwaysShow: true,
),
message: textMessage,
index: index,
),
FlyerChatImageMessage(
topWidget:
message.text?.isNotEmpty == true
? null
: TopWidget(
message,
groupStatus: groupStatus,
onTapReply:
notifier.scrollToMessage,
alwaysShow: true,
),
customImageProvider: CachedNetworkImage(
message.source,
ref.watch(
CrossCacheController.provider,
),
headers: ref.headers,
),
errorBuilder:
(context, error, stackTrace) =>
Center(
child: Text(
"Image Failed to Load",
style: TextStyle(
color: Theme.of(
context,
).colorScheme.error,
),
),
),
message: message,
index: index,
),
],
);
},
fileMessageBuilder:
(
_,
@ -574,22 +540,28 @@ class RoomChat extends HookConsumerWidget {
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showDialog(
context: context,
builder: (_) => Dialog(
child: Text("TODO: Download Attachments"),
}) => MessageWrapper(
message,
InkWell(
onTap: () => showDialog(
context: context,
builder: (_) => Dialog(
child: Text(
"TODO: Download Attachments",
),
),
),
child: FlyerChatFileMessage(
topWidget: TopWidget(
message,
onTapReply: notifier.scrollToMessage,
groupStatus: groupStatus,
),
message: message,
index: index,
),
),
child: FlyerChatFileMessage(
topWidget: TopWidget(
message,
onTapReply: notifier.scrollToMessage,
groupStatus: groupStatus,
),
message: message,
index: index,
),
groupStatus,
),
systemMessageBuilder:
(

View file

@ -19,107 +19,71 @@ class TopWidget extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Builder(
builder: (_) {
final replyMessage = message.metadata?["reply"] as Message?;
Widget build(BuildContext context, WidgetRef ref) {
final replyMessage = message.metadata?["reply"] as Message?;
if (replyMessage == null) return SizedBox.shrink();
if (replyMessage == null) return SizedBox.shrink();
final smallerText =
message is TextMessage && replyMessage.metadata!["body"] != null
? replyMessage.metadata!["body"].substring(
0,
min(
max(
max(
(message as TextMessage).text.length -
(replyMessage.metadata?["displayName"] as String)
.length -
5,
message.metadata?["displayName"].length,
),
final smallerText =
message is TextMessage && replyMessage.metadata!["body"] != null
? replyMessage.metadata!["body"].substring(
0,
min(
max(
max(
(message as TextMessage).text.length -
(replyMessage.metadata?["displayName"] as String).length -
5,
),
replyMessage.metadata!["body"].length,
),
)
: null;
final replyText =
(smallerText == null ||
smallerText.length == replyMessage.metadata!["body"].length)
? replyMessage.metadata!["body"]
: "$smallerText...";
return Padding(
padding: EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => onTapReply?.call(replyMessage),
child: Quoted(
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
AvatarOrHash(
Uri.tryParse(replyMessage.metadata?["avatarUrl"] ?? ""),
replyMessage.metadata?["displayName"] ?? "",
height: 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(
replyText,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium,
maxLines: 1,
),
),
],
message.metadata?["displayName"].length,
),
5,
),
replyMessage.metadata!["body"].length,
),
);
},
),
if (alwaysShow ||
groupStatus?.isFirst != false ||
message.metadata?["reply"] != null)
InkWell(
onTap: () => showDialog(
context: context,
builder: (_) =>
Dialog(child: Text("TODO: Show user profile")), // TODO
),
child: Row(
)
: null;
final replyText =
(smallerText == null ||
smallerText.length == replyMessage.metadata!["body"].length)
? replyMessage.metadata!["body"]
: "$smallerText...";
return Padding(
padding: EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => onTapReply?.call(replyMessage),
child: Quoted(
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
AvatarOrHash(
Uri.parse(message.metadata?["avatarUrl"] ?? ""),
message.metadata?["displayName"] ?? "",
Uri.tryParse(replyMessage.metadata?["avatarUrl"] ?? ""),
replyMessage.metadata?["displayName"] ?? "",
height: 16,
),
Flexible(
child: Text(
message.metadata?["displayName"] ?? message.authorId,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
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,
),
),
],
),
),
],
);
),
);
}
}