diff --git a/lib/helpers/build_event_options.dart b/lib/helpers/build_event_options.dart deleted file mode 100644 index b713351..0000000 --- a/lib/helpers/build_event_options.dart +++ /dev/null @@ -1,256 +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: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/main.dart b/lib/main.dart index 7522e79..ac9ef22 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -71,15 +71,15 @@ void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); + isInBackground = + Platform.environment["FLUTTER_HEADLESS"] != null || + args.contains("--unifiedpush-bg"); + LicenseRegistry.addLicense(() => .fromIterable(fontLicenses)); FlutterError.onError = (FlutterErrorDetails details) => showError(details.exception.toString(), details.stack); - isInBackground = - Platform.environment["FLUTTER_HEADLESS"] != null || - args.contains("--unifiedpush-bg"); - if (isInBackground) { await ProviderContainer() .read(UnifiedPushController.provider.future) diff --git a/lib/pages/chat.dart b/lib/pages/chat.dart index 5651b8d..fb165d3 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/room_chat.dart"; +import "package:nexus/widgets/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 new file mode 100644 index 0000000..623e71f --- /dev/null +++ b/lib/widgets/room_chat.dart @@ -0,0 +1,592 @@ +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 deleted file mode 100644 index 21f807f..0000000 --- a/lib/widgets/room_chat/chat_timeline.dart +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index d6e8325..0000000 --- a/lib/widgets/room_chat/room_chat.dart +++ /dev/null @@ -1,289 +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/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), - ], - ), - ), - ), - ); - } -}