From 6ad4657d766f13c506243b3dbd63b5d33201aaf6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 24 Sep 2026 14:24:41 -0400 Subject: [PATCH] refactor chatview --- lib/helpers/build_event_options.dart | 256 ++++++++++ lib/pages/chat.dart | 2 +- lib/widgets/room_chat.dart | 592 ----------------------- lib/widgets/room_chat/chat_timeline.dart | 76 +++ lib/widgets/room_chat/room_chat.dart | 289 +++++++++++ 5 files changed, 622 insertions(+), 593 deletions(-) create mode 100644 lib/helpers/build_event_options.dart delete mode 100644 lib/widgets/room_chat.dart create mode 100644 lib/widgets/room_chat/chat_timeline.dart create mode 100644 lib/widgets/room_chat/room_chat.dart diff --git a/lib/helpers/build_event_options.dart b/lib/helpers/build_event_options.dart new file mode 100644 index 0000000..b713351 --- /dev/null +++ b/lib/helpers/build_event_options.dart @@ -0,0 +1,256 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/pinned_ids.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/recent_emoji.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/via.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/widgets/emoji_picker.dart"; +import "package:nexus/main.dart"; + +IList buildEventOptions({ + required BuildContext context, + required WidgetRef ref, + required String roomId, + required String userId, + required Event event, + required void Function(Event, RelationType) onRelation, +}) { + final theme = Theme.of(context); + final danger = theme.colorScheme.error; + + final notifier = ref.read(RoomChatController.provider(roomId).notifier); + final client = ref.read(ClientController.provider.notifier); + + final isPinned = ref + .watch(PinnedIdsController.provider(roomId)) + .contains(event.eventId); + + Future sendReaction(String emoji) async { + await notifier.sendReaction(emoji, event).onError(showError); + + await ref + .read(RecentEmojiController.provider.notifier) + .add(emoji) + .onError(showError); + } + + void showReasonDialog({ + required String title, + required String description, + required String action, + required Future Function(String reason) onConfirm, + }) { + showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (context) { + final reasonController = useTextEditingController(); + + return AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text(description), + const SizedBox(height: 12), + TextField( + controller: reasonController, + textCapitalization: .sentences, + decoration: .new(labelText: "Reason (optional)"), + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final reason = reasonController.text; + Navigator.of(context).pop(); + + onConfirm(reason).onError(showError); + }, + child: Text(action), + ), + ], + ); + }, + ), + ); + } + + return .new([ + if (ref.watch( + PowerLevelController.provider(.new(eventType: .reaction, roomId: roomId)), + )) + PopupMenuItem( + enabled: false, + child: IconTheme( + data: theme.iconTheme, + child: Row( + children: [ + for (final emoji in { + ...ref + .watch(RecentEmojiController.provider) + .map((entry) => entry.emoji), + "👍", + "🤣", + "😭", + "🤔", + }.take(4)) + IconButton( + icon: Text(emoji), + onPressed: () { + Navigator.of(context).pop(); + sendReaction(emoji); + }, + ), + IconButton( + icon: const Icon(Icons.emoji_emotions), + onPressed: () { + Navigator.of(context).pop(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => EmojiPicker( + allowFreeText: true, + onSelection: (emoji) { + Navigator.of(context).pop(); + sendReaction(emoji); + }, + ), + ); + }, + ), + ], + ), + ), + ), + + if (ref.watch( + PowerLevelController.provider(.new(eventType: .message, roomId: roomId)), + )) + PopupMenuItem( + onTap: () => onRelation(event, .reply), + child: const ListTile(leading: Icon(Icons.reply), title: Text("Reply")), + ), + + if (event.content is MessageContent && event.sender == userId) + PopupMenuItem( + onTap: () => onRelation(event, .edit), + child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")), + ), + + if (ref.watch( + PowerLevelController.provider( + .state(eventType: .pinnedEvents, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () async { + try { + final pins = ref.read( + PinnedIdsController.provider(roomId).notifier, + ); + + if (isPinned) { + await pins.removePin(event.eventId); + } else { + await pins.addPin(event.eventId); + } + } catch (error, stackTrace) { + showError(error, stackTrace); + } + }, + child: ListTile( + leading: const Icon(Icons.push_pin), + title: Text(isPinned ? "Unpin Event" : "Pin Event"), + ), + ), + + PopupMenuItem( + onTap: () async { + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + if (room == null) return; + + final vias = ref.read(ViaController.provider(room)); + + await Clipboard.setData( + ClipboardData( + text: + "matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias", + ), + ); + }, + child: const ListTile( + leading: Icon(Icons.link), + title: Text("Copy Link"), + ), + ), + + if (event.content case MessageContent(:final body?)) + PopupMenuItem( + onTap: () => Clipboard.setData(ClipboardData(text: body)), + child: const ListTile( + leading: Icon(Icons.copy), + title: Text("Copy Text"), + ), + ), + + if (ref.watch( + PowerLevelController.provider( + .redaction(targetUser: event.sender, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () => showReasonDialog( + title: "Delete Message", + description: + "Are you sure you want to delete this message? " + "This cannot be reversed.", + action: "Delete", + onConfirm: (reason) => notifier.deleteMessage(event, reason: reason), + ), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Delete", style: .new(color: danger)), + ), + ), + + PopupMenuItem( + onTap: () => showReasonDialog( + title: "Report", + description: + "Report this event to your server administrators, " + "who can take action like banning this server or room.", + action: "Report", + onConfirm: (reason) => client.reportEvent( + .new( + roomId: roomId, + eventId: event.eventId, + reason: reason.isEmpty ? null : reason, + ), + ), + ), + child: ListTile( + leading: Icon(Icons.report, color: danger), + title: Text("Report", style: .new(color: danger)), + ), + ), + ]); +} diff --git a/lib/pages/chat.dart b/lib/pages/chat.dart index fb165d3..5651b8d 100644 --- a/lib/pages/chat.dart +++ b/lib/pages/chat.dart @@ -4,7 +4,7 @@ import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/key.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/sidebar.dart"; -import "package:nexus/widgets/room_chat.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; import "package:nexus/widgets/loading.dart"; class const ChatPage({super.key}) extends ConsumerWidget { diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart deleted file mode 100644 index 623e71f..0000000 --- a/lib/widgets/room_chat.dart +++ /dev/null @@ -1,592 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:material_ui/material_ui.dart"; -import "package:flutter/services.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:measure_size/measure_size.dart"; -import "package:nexus/controllers/client.dart"; -import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/member_list_opened.dart"; -import "package:nexus/controllers/pinned_ids.dart"; -import "package:nexus/controllers/power_level.dart"; -import "package:nexus/controllers/recent_emoji.dart"; -import "package:nexus/controllers/rooms.dart"; -import "package:nexus/controllers/room_chat.dart"; -import "package:nexus/controllers/via.dart"; -import "package:nexus/models/content/message.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/composer/composer.dart"; -import "package:nexus/widgets/emoji_picker.dart"; -import "package:nexus/widgets/pinned_events_drawer.dart"; -import "package:nexus/widgets/renderers/event.dart"; -import "package:nexus/widgets/member_list.dart"; -import "package:nexus/widgets/room_appbar.dart"; -import "package:nexus/widgets/highlight_wrapper.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/main.dart"; -import "package:nexus/widgets/loading.dart"; -import "package:super_sliver_list/super_sliver_list.dart"; - -final class const RoomChat({ - required final String? roomId, - required final bool isDesktop, - required final bool showMembersByDefault, - super.key, -}) extends HookConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final relatedEvent = useState(null); - final relationType = useState(RelationType.reply); - final highlightedEvent = useState(null); - - final composerSize = useState(64); - - final userId = ref.watch(ClientStateController.provider)?.userId; - final memberListOpened = ref - .watch(MemberListOpenedController.provider) - .requireValue; - final theme = Theme.of(context); - - final nothing = Center( - child: Text( - "Nothing to see here...", - style: theme.textTheme.headlineMedium, - ), - ); - if (userId == null || this.roomId == null) { - return Scaffold( - appBar: RoomAppbar( - roomId: this.roomId, - isDesktop: isDesktop, - onOpenDrawer: () => Scaffold.of(context).openDrawer(), - ), - body: nothing, - ); - } - - final roomId = this.roomId!; - - final controllerProvider = RoomChatController.provider(roomId); - final notifier = ref.watch(controllerProvider.notifier); - - final client = ref.watch(ClientController.provider.notifier); - - final listController = useRef(ListController()); - final scrollController = useScrollController(); - final controllerData = ref.watch(controllerProvider); - - final topEventBeforeLoad = useState(null); - final hasMore = useState(true); - - Future jumpToId(String eventId) async { - final index = - controllerData.value?.indexWhere( - (element) => element.eventId == eventId, - ) ?? - -1; - if (index == -1) return; - - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => .new(milliseconds: 700), - curve: (_) => Curves.easeInOut, - ); - highlightedEvent.value = eventId; - await Future.delayed(.new(seconds: 1), () { - if (highlightedEvent.value == eventId) { - highlightedEvent.value = null; - } - }); - } - - Future loadOlder() async { - if (controllerData case AsyncData(:final value?)) { - topEventBeforeLoad.value = value.firstOrNull?.eventId; - hasMore.value = await notifier.loadOlder(); - } - } - - useEffect(() { - ref - .read(controllerProvider.future) - .then( - (_) => WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - scrollController.jumpTo( - scrollController.position.maxScrollExtent - .000001, - ); - } - }), - ); - - return null; - }, [scrollController.hasClients]); - - useEffect(() { - if (controllerData case AsyncData(:final value?) - when scrollController.hasClients) { - if (topEventBeforeLoad.value != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - final index = value.indexWhere( - (event) => event.eventId == topEventBeforeLoad.value, - ); - if (index != -1) { - listController.value.jumpToItem( - index: index, - scrollController: scrollController, - alignment: 0, - ); - } - } - topEventBeforeLoad.value = null; - }); - } else if (scrollController.position.atEdge && - scrollController.position.pixels != 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - scrollController.jumpTo( - scrollController.position.maxScrollExtent, - ); - } - }); - } - } - - return null; - }, [controllerData]); - - useEffect(() { - Future listener() async { - if (!scrollController.hasClients || !scrollController.position.atEdge) { - return; - } - - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), - ); - if (room == null) return; - - if (scrollController.position.pixels == 0) { - if (room.hasMore) { - await loadOlder(); - } - } else { - await client.markRead(room); - } - } - - scrollController.addListener(listener); - return () => scrollController.removeListener(listener); - }, [roomId, controllerData]); - - final composerNode = useFocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && event.logicalKey == .escape) { - relatedEvent.value = null; - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - ); - - IList getEventOptions(Event event) { - final danger = theme.colorScheme.error; - final isSentByMe = event.sender == userId; - - final recentEmoji = ref.watch(RecentEmojiController.provider); - - return [ - if (ref.watch( - PowerLevelController.provider( - .new(eventType: .reaction, roomId: roomId), - ), - )) - PopupMenuItem( - enabled: false, - child: IconTheme( - data: theme.iconTheme, - child: Row( - children: [ - ...{ - ...recentEmoji.map((entry) => entry.emoji), - "👍", - "🤣", - "😭", - "🤔", - } - .toIList() - .sublist(0, 4) - .map( - (emoji) => IconButton( - onPressed: () async { - Navigator.of(context).pop(); - await notifier - .sendReaction(emoji, event) - .onError(showError); - - ref - .watch(RecentEmojiController.provider.notifier) - .add(emoji) - .onError(showError); - }, - icon: Text(emoji), - ), - ), - IconButton( - icon: Icon(Icons.emoji_emotions), - - onPressed: () { - // TODO: More adaptive, e.g. dialog on desktop - Navigator.of(context).pop(); - showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (context) => EmojiPicker( - onSelection: (value) { - Navigator.of(context).pop(); - notifier - .sendReaction(value, event) - .onError(showError); - }, - allowFreeText: true, - ), - ); - }, - ), - ], - ), - ), - ), - if (ref.watch( - PowerLevelController.provider( - .new(eventType: .message, roomId: roomId), - ), - )) - PopupMenuItem( - onTap: () { - relatedEvent.value = event; - relationType.value = .reply; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), - ), - if (event.content is MessageContent && isSentByMe) - PopupMenuItem( - onTap: () { - relatedEvent.value = event; - relationType.value = .edit; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), - ), - if (ref.watch( - PowerLevelController.provider( - .state(eventType: .pinnedEvents, roomId: roomId), - ), - )) - switch (ref - .watch(PinnedIdsController.provider(roomId)) - .contains(event.eventId)) { - bool isPinned => PopupMenuItem( - onTap: () async { - try { - final notifier = ref.read( - PinnedIdsController.provider(roomId).notifier, - ); - if (isPinned) { - await notifier.removePin(event.eventId); - } else { - await notifier.addPin(event.eventId); - } - } catch (error, stackTrace) { - showError(error, stackTrace); - } - }, - child: ListTile( - leading: Icon(Icons.push_pin), - title: Text(isPinned == true ? "Unpin Event" : "Pin Event"), - ), - ), - }, - PopupMenuItem( - onTap: () async { - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), - ); - if (room == null) return; - - final vias = ref.watch(ViaController.provider(room)); - - await Clipboard.setData( - ClipboardData( - text: - "matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias", - ), - ); - }, - child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - ), - if (event.content case MessageContent(:final body?)) - PopupMenuItem( - onTap: () async { - await Clipboard.setData(ClipboardData(text: body)); - }, - child: ListTile( - leading: Icon(Icons.copy), - title: Text("Copy Text"), - ), - ), - if (ref.watch( - PowerLevelController.provider( - .redaction(targetUser: event.sender, roomId: roomId), - ), - )) - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final deleteReasonController = useTextEditingController(); - return AlertDialog( - title: Text("Delete Message"), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - "Are you sure you want to delete this message? This can not be reversed.", - ), - SizedBox(height: 12), - TextField( - controller: deleteReasonController, - textCapitalization: .sentences, - decoration: .new( - labelText: "Reason for deletion (optional)", - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - await notifier - .deleteMessage( - event, - reason: deleteReasonController.text, - ) - .onError(showError); - }, - child: Text("Delete"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.delete, color: danger), - title: Text("Delete", style: .new(color: danger)), - ), - ), - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final reasonController = useTextEditingController(); - return AlertDialog( - title: Text("Report"), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - "Report this event to your server administrators, who can take action like banning this server or room.", - ), - - SizedBox(height: 12), - TextField( - controller: reasonController, - textCapitalization: .sentences, - decoration: .new( - labelText: "Reason for report (optional)", - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - client.reportEvent( - .new( - roomId: roomId, - eventId: event.eventId, - reason: reasonController.text.isEmpty - ? null - : reasonController.text, - ), - ); - Navigator.of(context).pop(); - }, - child: Text("Report"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.report, color: danger), - title: Text("Report", style: .new(color: danger)), - ), - ), - ].toIList(); - } - - return Scaffold( - endDrawer: PinnedEventsDrawer( - roomId, - getEventOptions: getEventOptions, - jumpToId: jumpToId, - ), - body: Builder( - builder: (middleContext) => Scaffold( - endDrawer: showMembersByDefault ? null : MemberList(roomId), - appBar: RoomAppbar( - roomId: roomId, - isDesktop: isDesktop, - onOpenDrawer: Scaffold.of(context).openDrawer, - onOpenMemberList: (thisContext) { - ref - .watch(MemberListOpenedController.provider.notifier) - .set(!memberListOpened); - Scaffold.of(thisContext).openEndDrawer(); - }, - onOpenPinnedMessagesList: () { - Scaffold.of(middleContext).openEndDrawer(); - }, - ), - body: Row( - children: [ - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: Padding( - padding: .symmetric(horizontal: 4), - child: switch (controllerData) { - AsyncData(:final value?) || - AsyncLoading(:final value?) => CustomScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - controller: scrollController, - slivers: [ - if (hasMore.value) - SliverToBoxAdapter( - child: Padding( - padding: .symmetric(vertical: 36), - child: Center( - child: ElevatedButton( - onPressed: controllerData is AsyncData - ? loadOlder - : null, - child: Text("Load More"), - ), - ), - ), - ), - - SuperSliverList.builder( - listController: listController.value, - itemCount: value.length, - itemBuilder: (_, index) { - final event = value[index]; - final previousEvent = value.getOrNull( - index - 1, - ); - return HighlightWrapper( - EventRenderer( - event, - onTapReply: () => - jumpToId(event.replyTo!), - getEventOptions: getEventOptions, - isGrouped: - previousEvent?.content - is MessageContent && - previousEvent?.redactedBy == null && - previousEvent?.relationType != - "m.replace" && - "${event.sender}${event.pmp?.id}" == - "${previousEvent?.sender}${previousEvent?.pmp?.id}", - ), - isHighlighted: - highlightedEvent.value == event.eventId, - ); - }, - ), - - SliverPadding( - padding: .only(bottom: composerSize.value), - ), - ], - ), - AsyncData() => nothing, - AsyncLoading() => Loading(), - AsyncError(:final error, :final stackTrace) => - ErrorDialog(error, stackTrace), - }, - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: MeasureSize( - onChange: (size) => composerSize.value = size.height, - child: Composer( - roomId, - node: composerNode, - onSend: - (text, {required shouldMention, required tags}) => - notifier - .send( - text, - tags: tags, - relationType: relationType.value, - shouldMention: shouldMention, - relation: relatedEvent.value, - ) - .onError(showError), - relationType: relationType.value, - relatedEvent: relatedEvent.value, - onDismiss: () => relatedEvent.value = null, - ), - ), - ), - ], - ), - ), - - if (memberListOpened == true && showMembersByDefault) - MemberList(roomId), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart new file mode 100644 index 0000000..21f807f --- /dev/null +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -0,0 +1,76 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/renderers/event.dart"; +import "package:nexus/widgets/highlight_wrapper.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; + +class const ChatTimeline({ + required final AsyncValue?> controllerData, + required final ScrollController scrollController, + required final ListController listController, + required final bool hasMore, + required final Future Function() loadOlder, + required final Future Function(String) jumpToId, + required final IList Function(Event) getEventOptions, + required final String? highlightedEvent, + required final double composerHeight, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => switch (controllerData) { + AsyncData(:final value?) || AsyncLoading(:final value?) => CustomScrollView( + keyboardDismissBehavior: .onDrag, + controller: scrollController, + slivers: [ + if (hasMore) + SliverToBoxAdapter( + child: Padding( + padding: .symmetric(vertical: 36), + child: Center( + child: ElevatedButton( + onPressed: controllerData is AsyncData ? loadOlder : null, + child: Text("Load More"), + ), + ), + ), + ), + + SuperSliverList.builder( + listController: listController, + itemCount: value.length, + itemBuilder: (_, index) { + final event = value[index]; + final previousEvent = value.getOrNull(index - 1); + return HighlightWrapper( + EventRenderer( + event, + onTapReply: () => jumpToId(event.replyTo!), + getEventOptions: getEventOptions, + isGrouped: + previousEvent?.content is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != "m.replace" && + event.sender == previousEvent?.sender && + event.pmp?.id == previousEvent?.pmp?.id, + ), + isHighlighted: highlightedEvent == event.eventId, + ); + }, + ), + + SliverPadding(padding: .only(bottom: composerHeight)), + ], + ), + AsyncData() => Center(child: Text("Nothing to see here...")), + AsyncLoading() => Loading(), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }; +} diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart new file mode 100644 index 0000000..d6e8325 --- /dev/null +++ b/lib/widgets/room_chat/room_chat.dart @@ -0,0 +1,289 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:measure_size/measure_size.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/member_list_opened.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/pinned_events_drawer.dart"; +import "package:nexus/widgets/member_list.dart"; +import "package:nexus/widgets/room_appbar.dart"; +import "package:nexus/main.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; +import "package:nexus/widgets/room_chat/chat_timeline.dart"; +import "package:nexus/helpers/build_event_options.dart"; + +final class const RoomChat({ + required final String? roomId, + required final bool isDesktop, + required final bool showMembersByDefault, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final relatedEvent = useState(null); + final relationType = useState(RelationType.reply); + final highlightedEvent = useState(null); + + final composerSize = useState(64); + + final userId = ref.watch(ClientStateController.provider)?.userId; + final memberListOpened = ref + .watch(MemberListOpenedController.provider) + .requireValue; + final theme = Theme.of(context); + + final nothing = Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, + ), + ); + if (userId == null || this.roomId == null) { + return Scaffold( + appBar: RoomAppbar( + roomId: this.roomId, + isDesktop: isDesktop, + onOpenDrawer: () => Scaffold.of(context).openDrawer(), + ), + body: nothing, + ); + } + + final roomId = this.roomId!; + + final controllerProvider = RoomChatController.provider(roomId); + final notifier = ref.watch(controllerProvider.notifier); + + final client = ref.watch(ClientController.provider.notifier); + + final listController = useRef(ListController()); + final scrollController = useScrollController(); + final controllerData = ref.watch(controllerProvider); + + final topEventBeforeLoad = useState(null); + final hasMore = useState(true); + final loadingOlder = useRef(false); + + Future jumpToId(String eventId) async { + final index = + controllerData.value?.indexWhere( + (element) => element.eventId == eventId, + ) ?? + -1; + if (index == -1) return; + + listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => .new(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + highlightedEvent.value = eventId; + await Future.delayed(.new(seconds: 1), () { + if (highlightedEvent.value == eventId) { + highlightedEvent.value = null; + } + }); + } + + Future loadOlder() async { + if (loadingOlder.value || !hasMore.value) return; + if (controllerData case AsyncData(:final value?)) { + loadingOlder.value = true; + topEventBeforeLoad.value = value.firstOrNull?.eventId; + try { + hasMore.value = await notifier.loadOlder(); + } finally { + loadingOlder.value = false; + } + } + } + + useEffect(() { + ref + .read(controllerProvider.future) + .then( + (_) => WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + scrollController.jumpTo( + scrollController.position.maxScrollExtent - .000001, + ); + } + }), + ); + + return null; + }, [scrollController.hasClients]); + + useEffect(() { + if (controllerData case AsyncData(:final value?) + when scrollController.hasClients) { + if (topEventBeforeLoad.value != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + final index = value.indexWhere( + (event) => event.eventId == topEventBeforeLoad.value, + ); + if (index != -1) { + listController.value.jumpToItem( + index: index, + scrollController: scrollController, + alignment: 0, + ); + } + } + topEventBeforeLoad.value = null; + }); + } else if (scrollController.position.atEdge && + scrollController.position.pixels != 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + scrollController.jumpTo( + scrollController.position.maxScrollExtent, + ); + } + }); + } + } + + return null; + }, [controllerData]); + + useEffect(() { + Future listener() async { + if (!scrollController.hasClients || !scrollController.position.atEdge) { + return; + } + + final room = ref.read( + RoomsController.provider.select((value) => value[roomId]), + ); + if (room == null) return; + + if (scrollController.position.pixels == 0) { + if (room.hasMore) { + await loadOlder(); + } + } else { + await client.markRead(room); + } + } + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [roomId, controllerData]); + + final composerNode = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey == .escape) { + relatedEvent.value = null; + return .handled; + } + + return .ignored; + }, + ); + + IList getEventOptions(Event event) => buildEventOptions( + context: context, + ref: ref, + roomId: roomId, + userId: userId, + event: event, + onRelation: (event, type) { + relatedEvent.value = event; + relationType.value = type; + composerNode.requestFocus(); + }, + ); + + return Scaffold( + endDrawer: PinnedEventsDrawer( + roomId, + getEventOptions: getEventOptions, + jumpToId: jumpToId, + ), + body: Builder( + builder: (middleContext) => Scaffold( + endDrawer: showMembersByDefault ? null : MemberList(roomId), + appBar: RoomAppbar( + roomId: roomId, + isDesktop: isDesktop, + onOpenDrawer: Scaffold.of(context).openDrawer, + onOpenMemberList: (thisContext) { + ref + .read(MemberListOpenedController.provider.notifier) + .set(!memberListOpened); + Scaffold.of(thisContext).openEndDrawer(); + }, + onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer, + ), + body: Row( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: ChatTimeline( + controllerData: controllerData, + scrollController: scrollController, + listController: listController.value, + hasMore: hasMore.value, + loadOlder: loadOlder, + jumpToId: jumpToId, + getEventOptions: getEventOptions, + highlightedEvent: highlightedEvent.value, + composerHeight: composerSize.value, + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MeasureSize( + onChange: (size) => composerSize.value = size.height, + child: Composer( + roomId, + node: composerNode, + onSend: + (text, {required shouldMention, required tags}) => + notifier + .send( + text, + tags: tags, + relationType: relationType.value, + shouldMention: shouldMention, + relation: relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () => relatedEvent.value = null, + ), + ), + ), + ], + ), + ), + + if (memberListOpened == true && showMembersByDefault) + MemberList(roomId), + ], + ), + ), + ), + ); + } +}