nexus/lib/widgets/chat_page/room_chat.dart
2026-01-15 20:03:36 -05:00

567 lines
30 KiB
Dart

import "package:cross_cache/cross_cache.dart";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_link_previewer/flutter_link_previewer.dart";
import "package:flyer_chat_file_message/flyer_chat_file_message.dart";
import "package:flyer_chat_image_message/flyer_chat_image_message.dart";
import "package:flyer_chat_system_message/flyer_chat_system_message.dart";
import "package:flyer_chat_text_message/flyer_chat_text_message.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart";
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/models/relation_type.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/room_appbar.dart";
import "package:nexus/widgets/chat_page/top_widget.dart";
import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/widgets/loading.dart";
// import "package:dynamic_polls/dynamic_polls.dart";
// import "package:matrix/matrix.dart";
class RoomChat extends HookConsumerWidget {
final bool isDesktop;
final bool showMembersByDefault;
const RoomChat({
required this.isDesktop,
required this.showMembersByDefault,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final replyToMessage = useState<Message?>(null);
final memberListOpened = useState<bool>(showMembersByDefault);
final relationType = useState(RelationType.reply);
final theme = Theme.of(context);
final danger = theme.colorScheme.error;
return ref
.watch(SelectedRoomController.provider)
.betterWhen(
data: (room) {
if (room == null) {
return Center(
child: Text(
"Nothing to see here...",
style: theme.textTheme.headlineMedium,
),
);
}
final controllerProvider = RoomChatController.provider(
room.roomData,
);
final notifier = ref.watch(controllerProvider.notifier);
List<PopupMenuEntry> getMessageOptions(Message message) => [
PopupMenuItem(
onTap: () {
replyToMessage.value = message;
relationType.value = RelationType.reply;
},
child: ListTile(
leading: Icon(Icons.reply),
title: Text("Reply"),
),
),
// Should check if is state event (has state_key), if so, don't show edit option
if (message is TextMessage &&
message.authorId == room.roomData.client.userID)
PopupMenuItem(
onTap: () {
replyToMessage.value = message;
relationType.value = RelationType.edit;
},
child: ListTile(
leading: Icon(Icons.edit),
title: Text("Edit"),
),
),
if (message.authorId == room.roomData.client.userID ||
room.roomData.canRedact)
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (_) {
final deleteReasonController =
useTextEditingController();
return AlertDialog(
title: Text("Delete Message"),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Are you sure you want to delete this message? This can not be reversed.",
),
SizedBox(height: 12),
FormTextInput(
required: false,
capitalize: true,
controller: deleteReasonController,
title: "Reason for deletion (optional)",
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () async {
notifier.deleteMessage(
message,
reason: deleteReasonController.text,
);
Navigator.of(context).pop();
},
child: Text("Delete"),
),
],
);
},
),
),
child: ListTile(
leading: Icon(Icons.delete),
title: Text("Delete"),
),
),
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text("Report"),
content: Text(
"Report this message to your server administrators, who can take action like banning that user or blocking that server from federating.",
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () {
room.roomData.client.reportEvent(
room.roomData.id,
message.id,
);
Navigator.of(context).pop();
},
child: Text("Report"),
),
],
),
),
child: ListTile(
leading: Icon(Icons.report, color: danger),
title: Text("Report", style: TextStyle(color: danger)),
),
),
];
return Scaffold(
appBar: RoomAppbar(
room,
isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: (thisContext) {
memberListOpened.value = !memberListOpened.value;
Scaffold.of(thisContext).openEndDrawer();
},
),
body: Row(
children: [
Expanded(
child: Column(
children: [
Expanded(
child: ref
.watch(controllerProvider)
.betterWhen(
data: (controller) => Chat(
currentUserId: room.roomData.client.userID!,
theme: ChatTheme.fromThemeData(theme)
.copyWith(
colors: ChatColors.fromThemeData(theme)
.copyWith(
primary: theme
.colorScheme
.primaryContainer,
onPrimary: theme
.colorScheme
.onPrimaryContainer,
),
),
onMessageSecondaryTap:
(
context,
message, {
required index,
TapUpDetails? details,
}) => details?.globalPosition == null
? null
: context.showContextMenu(
globalPosition:
details!.globalPosition,
children: getMessageOptions(message),
),
onMessageLongPress:
(
context,
message, {
required details,
required index,
}) => context.showContextMenu(
globalPosition: details.globalPosition,
children: getMessageOptions(message),
),
onMessageTap:
(
context,
message, {
required details,
required index,
}) {
if (message is ImageMessage) {
showDialog(
context: context,
builder: (_) => Dialog(
backgroundColor:
Colors.transparent,
insetPadding: EdgeInsets.all(64),
child: InteractiveViewer(
child: Image(
image: CachedNetworkImage(
message.source,
ref.watch(
CrossCacheController
.provider,
),
headers: room
.roomData
.client
.headers,
),
),
),
),
);
}
},
builders: Builders(
loadMoreBuilder: (_) => Loading(),
chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList(
itemBuilder: itemBuilder,
onEndReached: notifier.loadOlder,
onStartReached: notifier.markRead,
bottomPadding: 72,
),
composerBuilder: (_) => ChatBox(
relationType: relationType.value,
relatedMessage: replyToMessage.value,
onDismiss: () =>
replyToMessage.value = null,
room: room.roomData,
),
// customMessageBuilder:
// (
// context,
// message,
// index, {
// required bool isSentByMe,
// MessageGroupStatus? groupStatus,
// }) {
// final poll =
// message.metadata?["poll"]
// as PollStartContent;
// final responses =
// (message.metadata?["responses"]
// as Map<
// String,
// Set<String>
// >)
// .values
// .expand((set) => set)
// .fold(<String, int>{}, (
// acc,
// value,
// ) {
// acc[value] =
// (acc[value] ?? 0) + 1;
// return acc;
// });
// return Column(
// crossAxisAlignment:
// CrossAxisAlignment.start,
// spacing: 4,
// children: [
// TopWidget(
// message,
// headers: room
// .roomData
// .client
// .headers,
// groupStatus: groupStatus,
// ),
// // TODO: Make this actually work
// DynamicPolls(
// startDate: DateTime.now(),
// endDate: DateTime.now(),
// private:
// poll.kind ==
// PollKind.undisclosed,
// allowReselection: true,
// backgroundDecoration:
// BoxDecoration(
// borderRadius:
// BorderRadius.all(
// Radius.circular(16),
// ),
// border: Border.all(
// color: theme
// .colorScheme
// .primaryContainer,
// width: 4,
// ),
// ),
// allStyle: Styles(
// titleStyle: TitleStyle(
// style: theme
// .textTheme
// .headlineSmall,
// ),
// optionStyle: OptionStyle(
// fillColor: theme
// .colorScheme
// .primaryContainer,
// selectedBorderColor: theme
// .colorScheme
// .primary,
// borderColor: theme
// .colorScheme
// .primary,
// unselectedBorderColor:
// Colors.transparent,
// textSelectColor: theme
// .colorScheme
// .primary,
// ),
// ),
// onOptionSelected:
// (int index) {},
// title: poll.question.mText,
// options: poll.answers
// .map(
// (option) => option.mText,
// )
// .toList(),
// ),
// ],
// );
// },
textMessageBuilder:
(
context,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatTextMessage(
customWidget: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Html(
(message.metadata?["formatted"]
as String)
.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/>"),
client: room.roomData.client,
),
if (message.editedAt != null)
Text(
"(edited)",
style: theme
.textTheme
.labelSmall,
),
],
),
topWidget: TopWidget(
message,
headers:
room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
showTime: true,
index: index,
),
linkPreviewBuilder:
(_, message, isSentByMe) => LinkPreview(
text: message.text,
backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary
: theme
.colorScheme
.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData:
message.linkPreviewData,
onLinkPreviewDataFetched:
(linkPreviewData) =>
notifier.updateMessage(
message,
message.copyWith(
linkPreviewData:
linkPreviewData,
),
),
),
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(
topWidget: TopWidget(
message,
headers:
room.roomData.client.headers,
groupStatus: groupStatus,
alwaysShow: true,
),
customImageProvider:
CachedNetworkImage(
message.source,
ref.watch(
CrossCacheController.provider,
),
headers: room
.roomData
.client
.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:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showDialog(
context: context,
builder: (_) => Dialog(
child: Text(
"TODO: Download Attachments", // TODO
),
),
),
child: FlyerChatFileMessage(
topWidget: TopWidget(
message,
headers:
room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
index: index,
),
),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(
message: message,
index: index,
),
unsupportedMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => Text(
"${message.authorId} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall
?.copyWith(color: Colors.grey),
),
),
resolveUser: notifier.resolveUser,
chatController: controller,
),
),
),
],
),
),
if (memberListOpened.value == true && showMembersByDefault)
MemberList(room.roomData),
],
),
endDrawer: showMembersByDefault
? null
: MemberList(room.roomData),
);
},
);
}
}