import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.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/account_data.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/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_button.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"; class RoomChat extends HookConsumerWidget { final bool isDesktop; final bool showMembersByDefault; final String? roomId; const RoomChat({ required this.roomId, required this.isDesktop, required this.showMembersByDefault, super.key, }); @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); 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 scrollController = useScrollController(); final controllerData = ref.watch(controllerProvider); final historyListController = useRef(ListController()); final liveListController = useRef(ListController()); final timelineCenterKey = useMemoized(GlobalKey.new); final timelineBoundary = useRef(null); final hasMore = ref.watch( RoomsController.provider.select( (rooms) => rooms[roomId]?.hasMore ?? false, ), ); Future jumpToId(String eventId) async { final events = controllerData.value; if (events == null || !scrollController.hasClients) return; final sourceIndex = events.indexWhere( (event) => event.eventId == eventId, ); final boundaryIndex = events.indexWhere( (event) => event.eventId == timelineBoundary.value, ); if (sourceIndex < 0 || boundaryIndex < 0) return; final listController = sourceIndex <= boundaryIndex ? historyListController.value : liveListController.value; if (!listController.isAttached) return; listController.animateToItem( index: sourceIndex <= boundaryIndex ? boundaryIndex - sourceIndex : sourceIndex - boundaryIndex - 1, 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; } }); } 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; return [ if (ref.watch( PowerLevelController.provider( .new(eventType: .reaction, roomId: roomId), ), )) PopupMenuItem( enabled: false, child: IconTheme( data: theme.iconTheme, child: Row( children: [ ...{ ...ref.watch( AccountDataController.provider.select( (value) => value.recentEmoji .map((entry) => entry.emoji) .toIList(), ), ), "👍", "🤣", "😭", "🤔", } .toIList() .sublist(0, 4) .map( (emoji) => IconButton( onPressed: () async { Navigator.of(context).pop(); await notifier .sendReaction(emoji, event) .onError(showError); }, icon: Text(emoji), ), ), EmojiPickerButton( context: context, onPressed: Navigator.of(context).pop, onSelection: (emoji) => notifier.sendReaction(emoji, event).onError(showError), ), ], ), ), ), 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 (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?) => Builder( builder: (_) { if (value.isEmpty) return nothing; var boundaryIndex = value.indexWhere( (event) => event.eventId == timelineBoundary.value, ); if (boundaryIndex < 0) { boundaryIndex = value.length - 1; timelineBoundary.value = value[boundaryIndex].eventId; } final historyLength = boundaryIndex + 1; final liveLength = value.length - historyLength; int? eventIndexForKey(Key key) { if (key is! ValueKey) return null; final index = value.indexWhere( (event) => event.eventId == key.value, ); return index < 0 ? null : index; } int? historyChildIndex(Key key) { if (key == const ValueKey("history-loader")) { return historyLength; } final eventIndex = eventIndexForKey(key); return eventIndex == null || eventIndex >= historyLength ? null : historyLength - eventIndex - 1; } int? liveChildIndex(Key key) { final eventIndex = eventIndexForKey(key); return eventIndex == null || eventIndex < historyLength ? null : eventIndex - historyLength; } Widget eventRow( Event event, Event? previousEvent, ) => 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}", ), key: ValueKey(event.eventId), isHighlighted: highlightedEvent.value == event.eventId, ); return CustomScrollView( reverse: true, center: timelineCenterKey, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, controller: scrollController, slivers: [ SliverToBoxAdapter( child: SizedBox(height: composerSize.value), ), SuperSliverList.builder( listController: liveListController.value, itemCount: liveLength, findChildIndexCallback: liveChildIndex, itemBuilder: (_, index) => eventRow( value[historyLength + index], value.getOrNull( historyLength + index - 1, ), ), ), SuperSliverList.builder( key: timelineCenterKey, listController: historyListController.value, itemCount: historyLength + 1, findChildIndexCallback: historyChildIndex, itemBuilder: (_, index) { if (index == historyLength) { return _HistoryLoader( key: const ValueKey("history-loader"), hasMore: hasMore, isLoading: controllerData is AsyncLoading, onLoad: notifier.loadOlder, ); } final event = value[historyLength - index - 1]; return eventRow( event, value.getOrNull( historyLength - index - 2, ), ); }, ), ], ); }, ), 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), ], ), ), ), ); } } class _HistoryLoader extends HookWidget { final bool hasMore; final bool isLoading; final Future Function() onLoad; const _HistoryLoader({ required this.hasMore, required this.isLoading, required this.onLoad, super.key, }); @override Widget build(BuildContext context) { useEffect(() { if (hasMore) { Future.microtask(onLoad); } return null; }, const []); return Padding( padding: .symmetric(vertical: 36), child: Center( child: ElevatedButton( onPressed: hasMore && !isLoading ? onLoad : null, child: isLoading ? SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(hasMore ? "Load More" : "Start of timeline"), ), ), ); } }