content: import 'dart:async';
  import 'dart:io';
  
  import 'package:flutter/foundation.dart';
  import 'package:flutter/material.dart';
  import 'package:flutter/services.dart';
  
  import 'package:collection/collection.dart';
  import 'package:desktop_drop/desktop_drop.dart';
  import 'package:device_info_plus/device_info_plus.dart';
  import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
  import 'package:go_router/go_router.dart';
  import 'package:image_picker/image_picker.dart';
  import 'package:matrix/matrix.dart';
  import 'package:scroll_to_index/scroll_to_index.dart';
  import 'package:shared_preferences/shared_preferences.dart';
  import 'package:universal_html/html.dart' as html;
  
  import 'package:fluffychat/config/app_config.dart';
  import 'package:fluffychat/config/setting_keys.dart';
  import 'package:fluffychat/config/themes.dart';
  import 'package:fluffychat/l10n/l10n.dart';
  import 'package:fluffychat/pages/chat/chat_view.dart';
  import 'package:fluffychat/pages/chat/event_info_dialog.dart';
  import 'package:fluffychat/pages/chat_details/chat_details.dart';
  import 'package:fluffychat/utils/error_reporter.dart';
  import 'package:fluffychat/utils/file_selector.dart';
  import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart';
  import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart';
  import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
  import 'package:fluffychat/utils/other_party_can_receive.dart';
  import 'package:fluffychat/utils/platform_infos.dart';
  import 'package:fluffychat/utils/show_scaffold_dialog.dart';
  import 'package:fluffychat/widgets/adaptive_dialogs/show_modal_action_popup.dart';
  import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
  import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
  import 'package:fluffychat/widgets/future_loading_dialog.dart';
  import 'package:fluffychat/widgets/matrix.dart';
  import 'package:fluffychat/widgets/share_scaffold_dialog.dart';
  import '../../utils/account_bundles.dart';
  import '../../utils/localized_exception_extension.dart';
  import 'send_file_dialog.dart';
  import 'send_location_dialog.dart';
  
  class ChatPage extends StatelessWidget {
    final String roomId;
    final List<ShareItem>? shareItems;
    final String? eventId;
  
    const ChatPage({
      super.key,
      required this.roomId,
      this.eventId,
      this.shareItems,
    });
  
    @override
    Widget build(BuildContext context) {
      final room = Matrix.of(context).client.getRoomById(roomId);
      if (room == null) {
        return Scaffold(
          appBar: AppBar(title: Text(L10n.of(context).oopsSomethingWentWrong)),
          body: Center(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
            ),
          ),
        );
      }
  
      return ChatPageWithRoom(
        key: Key('chat_page_${roomId}_$eventId'),
        room: room,
        shareItems: shareItems,
        eventId: eventId,
      );
    }
  }
  
  class ChatPageWithRoom extends StatefulWidget {
    final Room room;
    final List<ShareItem>? shareItems;
    final String? eventId;
  
    const ChatPageWithRoom({
      super.key,
      required this.room,
      this.shareItems,
      this.eventId,
    });
  
    @override
    ChatController createState() => ChatController();
  }
  
  class ChatController extends State<ChatPageWithRoom>
      with WidgetsBindingObserver {
    Room get room => sendingClient.getRoomById(roomId) ?? widget.room;
  
    late Client sendingClient;
  
    Timeline? timeline;
  
    late final String readMarkerEventId;
  
    String get roomId => widget.room.id;
  
    final AutoScrollController scrollController = AutoScrollController();
  
    late final FocusNode inputFocus;
    StreamSubscription<html.Event>? onFocusSub;
  
    Timer? typingCoolDown;
    Timer? typingTimeout;
    bool currentlyTyping = false;
    bool dragging = false;
  
    void onDragEntered(_) => setState(() => dragging = true);
  
    void onDragExited(_) => setState(() => dragging = false);
  
    void onDragDone(DropDoneDetails details) async {
      setState(() => dragging = false);
      if (details.files.isEmpty) return;
  
      await showAdaptiveDialog(
        context: context,
        builder: (c) => SendFileDialog(
          files: details.files,
          room: room,
          outerContext: context,
        ),
      );
    }
  
    bool get canSaveSelectedEvent =>
        selectedEvents.length == 1 &&
        {
          MessageTypes.Video,
          MessageTypes.Image,
          MessageTypes.Sticker,
          MessageTypes.Audio,
          MessageTypes.File,
        }.contains(selectedEvents.single.messageType);
  
    void saveSelectedEvent(context) => selectedEvents.single.saveFile(context);
  
    List<Event> selectedEvents = [];
  
    final Set<String> unfolded = {};
  
    Event? replyEvent;
  
    Event? editEvent;
  
    bool _scrolledUp = false;
  
    bool get showScrollDownButton =>
        _scrolledUp || timeline?.allowNewEvent == false;
  
    bool get selectMode => selectedEvents.isNotEmpty;
  
    final int _loadHistoryCount = 100;
  
    String pendingText = '';
  
    bool showEmojiPicker = false;
  
    void recreateChat() async {
      final room = this.room;
      final userId = room.directChatMatrixID;
      if (userId == null) {
        throw Exception(
          'Try to recreate a room with is not a DM room. This should not be possible from the UI!',
        );
      }
      await showFutureLoadingDialog(
        context: context,
        future: () => room.invite(userId),
      );
    }
  
    void leaveChat() async {
      final success = await showFutureLoadingDialog(
        context: context,
        future: room.leave,
      );
      if (success.error != null) return;
      context.go('/rooms');
    }
  
    void requestHistory([_]) async {
      Logs().v('Requesting history...');
      await timeline?.requestHistory(historyCount: _loadHistoryCount);
    }
  
    void requestFuture() async {
      final timeline = this.timeline;
      if (timeline == null) return;
      Logs().v('Requesting future...');
      final mostRecentEventId = timeline.events.first.eventId;
      await timeline.requestFuture(historyCount: _loadHistoryCount);
      setReadMarker(eventId: mostRecentEventId);
    }
  
    void _updateScrollController() {
      if (!mounted) {
        return;
      }
      if (!scrollController.hasClients) return;
      if (timeline?.allowNewEvent == false ||
          scrollController.position.pixels > 0 && _scrolledUp == false) {
        setState(() => _scrolledUp = true);
      } else if (scrollController.position.pixels <= 0 && _scrolledUp == true) {
        setState(() => _scrolledUp = false);
        setReadMarker();
      }
  
      if (scrollController.position.pixels == 0 ||
          scrollController.position.pixels == 64) {
        requestFuture();
      }
    }
  
    void _loadDraft() async {
      final prefs = await SharedPreferences.getInstance();
      final draft = prefs.getString('draft_$roomId');
      if (draft != null && draft.isNotEmpty) {
        sendController.text = draft;
      }
    }
  
    void _shareItems([_]) {
      final shareItems = widget.shareItems;
      if (shareItems == null || shareItems.isEmpty) return;
      if (!room.otherPartyCanReceiveMessages) {
        final theme = Theme.of(context);
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            backgroundColor: theme.colorScheme.errorContainer,
            closeIconColor: theme.colorScheme.onErrorContainer,
            content: Text(
              L10n.of(context).otherPartyNotLoggedIn,
              style: TextStyle(
                color: theme.colorScheme.onErrorContainer,
              ),
            ),
            showCloseIcon: true,
          ),
        );
        return;
      }
      for (final item in shareItems) {
        if (item is FileShareItem) continue;
        if (item is TextShareItem) room.sendTextEvent(item.value);
        if (item is ContentShareItem) room.sendEvent(item.value);
      }
      final files = shareItems
          .whereType<FileShareItem>()
          .map((item) => item.value)
          .toList();
      if (files.isEmpty) return;
      showAdaptiveDialog(
        context: context,
        builder: (c) => SendFileDialog(
          files: files,
          room: room,
          outerContext: context,
        ),
      );
    }
  
    KeyEventResult _customEnterKeyHandling(FocusNode node, KeyEvent evt) {
      if (!HardwareKeyboard.instance.isShiftPressed &&
          evt.logicalKey.keyLabel == 'Enter' &&
          (AppConfig.sendOnEnter ?? !PlatformInfos.isMobile)) {
        if (evt is KeyDownEvent) {
          send();
        }
        return KeyEventResult.handled;
      } else if (evt.logicalKey.keyLabel == 'Enter' && evt is KeyDownEvent) {
        final currentLineNum = sendController.text
                .substring(
                  0,
                  sendController.selection.baseOffset,
                )
                .split('\n')
                .length -
            1;
        final currentLine = sendController.text.split('\n')[currentLineNum];
  
        for (final pattern in [
          '- [ ] ',
          '- [x] ',
          '* [ ] ',
          '* [x] ',
          '- ',
          '* ',
          '+ ',
        ]) {
          if (currentLine.startsWith(pattern)) {
            if (currentLine == pattern) {
              return KeyEventResult.ignored;
            }
            sendController.text += '\n$pattern';
            return KeyEventResult.handled;
          }
        }
  
        return KeyEventResult.ignored;
      } else {
        return KeyEventResult.ignored;
      }
    }
  
    @override
    void initState() {
      inputFocus = FocusNode(onKeyEvent: _customEnterKeyHandling);
  
      scrollController.addListener(_updateScrollController);
      inputFocus.addListener(_inputFocusListener);
  
      _loadDraft();
      WidgetsBinding.instance.addPostFrameCallback(_shareItems);
      super.initState();
      _displayChatDetailsColumn = ValueNotifier(
        AppSettings.displayChatDetailsColumn.getItem(Matrix.of(context).store),
      );
  
      sendingClient = Matrix.of(context).client;
      readMarkerEventId = room.hasNewMessages ? room.fullyRead : '';
      WidgetsBinding.instance.addObserver(this);
      _tryLoadTimeline();
      if (kIsWeb) {
        onFocusSub = html.window.onFocus.listen((_) => setReadMarker());
      }
    }
  
    final Set<String> expandedEventIds = {};
  
    void expandEventsFrom(Event event, bool expand) {
      final events = timeline!.events.filterByVisibleInGui();
      final start = events.indexOf(event);
      setState(() {
        for (var i = start; i < events.length; i++) {
          final event = events[i];
          if (!event.isCollapsedState) return;
          if (expand) {
            expandedEventIds.add(event.eventId);
          } else {
            expandedEventIds.remove(event.eventId);
          }
        }
      });
    }
  
    void _tryLoadTimeline() async {
      final initialEventId = widget.eventId;
      loadTimelineFuture = _getTimeline();
      try {
        await loadTimelineFuture;
        if (initialEventId != null) scrollToEventId(initialEventId);
  
        var readMarkerEventIndex = readMarkerEventId.isEmpty
            ? -1
            : timeline!.events
                .filterByVisibleInGui(exceptionEventId: readMarkerEventId)
                .indexWhere((e) => e.eventId == readMarkerEventId);
  
        // Read marker is existing but not found in first events. Try a single
        // requestHistory call before opening timeline on event context:
        if (readMarkerEventId.isNotEmpty && readMarkerEventIndex == -1) {
          await timeline?.requestHistory(historyCount: _loadHistoryCount);
          readMarkerEventIndex = timeline!.events
              .filterByVisibleInGui(exceptionEventId: readMarkerEventId)
              .indexWhere((e) => e.eventId == readMarkerEventId);
        }
  
        if (readMarkerEventIndex > 1) {
          Logs().v('Scroll up to visible event', readMarkerEventId);
          scrollToEventId(readMarkerEventId, highlightEvent: false);
          return;
        } else if (readMarkerEventId.isNotEmpty && readMarkerEventIndex == -1) {
          _showScrollUpMaterialBanner(readMarkerEventId);
        }
  
        // Mark room as read on first visit if requirements are fulfilled
        setReadMarker();
  
        if (!mounted) return;
      } catch (e, s) {
        ErrorReporter(context, 'Unable to load timeline').onErrorCallback(e, s);
        rethrow;
      }
    }
  
    String? scrollUpBannerEventId;
  
    void discardScrollUpBannerEventId() => setState(() {
          scrollUpBannerEventId = null;
        });
  
    void _showScrollUpMaterialBanner(String eventId) => setState(() {
          scrollUpBannerEventId = eventId;
        });
  
    void updateView() {
      if (!mounted) return;
      setReadMarker();
      setState(() {});
    }
  
    Future<void>? loadTimelineFuture;
  
    int? animateInEventIndex;
  
    void onInsert(int i) {
      // setState will be called by updateView() anyway
      animateInEventIndex = i;
    }
  
    Future<void> _getTimeline({
      String? eventContextId,
    }) async {
      await Matrix.of(context).client.roomsLoading;
      await Matrix.of(context).client.accountDataLoading;
      if (eventContextId != null &&
          (!eventContextId.isValidMatrixId || eventContextId.sigil != '\$')) {
        eventContextId = null;
      }
      try {
        timeline?.cancelSubscriptions();
        timeline = await room.getTimeline(
          onUpdate: updateView,
          eventContextId: eventContextId,
          onInsert: onInsert,
        );
      } catch (e, s) {
        Logs().w('Unable to load timeline on event ID $eventContextId', e, s);
        if (!mounted) return;
        timeline = await room.getTimeline(
          onUpdate: updateView,
          onInsert: onInsert,
        );
        if (!mounted) return;
        if (e is TimeoutException || e is IOException) {
          _showScrollUpMaterialBanner(eventContextId!);
        }
      }
      timeline!.requestKeys(onlineKeyBackupOnly: false);
      if (room.markedUnread) room.markUnread(false);
  
      return;
    }
  
    String? scrollToEventIdMarker;
  
    @override
    void didChangeAppLifecycleState(AppLifecycleState state) {
      if (state != AppLifecycleState.resumed) return;
      if (!mounted) return;
      setReadMarker();
    }
  
    Future<void>? _setReadMarkerFuture;
  
    void setReadMarker({String? eventId}) {
      if (eventId?.isValidMatrixId == false) return;
      if (_setReadMarkerFuture != null) return;
      if (_scrolledUp) return;
      if (scrollUpBannerEventId != null) return;
  
      if (eventId == null &&
          !room.hasNewMessages &&
          room.notificationCount == 0) {
        return;
      }
  
      // Do not send read markers when app is not in foreground
      if (kIsWeb && !Matrix.of(context).webHasFocus) return;
      if (!kIsWeb &&
          WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
        return;
      }
  
      final timeline = this.timeline;
      if (timeline == null || timeline.events.isEmpty) return;
  
      Logs().d('Set read marker...', eventId);
      // ignore: unawaited_futures
      _setReadMarkerFuture = timeline
          .setReadMarker(
        eventId: eventId,
        public: AppConfig.sendPublicReadReceipts,
      )
          .then((_) {
        _setReadMarkerFuture = null;
      });
      if (eventId == null || eventId == timeline.room.lastEvent?.eventId) {
        Matrix.of(context).backgroundPush?.cancelNotification(roomId);
      }
    }
  
    @override
    void dispose() {
      timeline?.cancelSubscriptions();
      timeline = null;
      inputFocus.removeListener(_inputFocusListener);
      onFocusSub?.cancel();
      super.dispose();
    }
  
    TextEditingController sendController = TextEditingController();
  
    void setSendingClient(Client c) {
      // first cancel typing with the old sending client
      if (currentlyTyping) {
        // no need to have the setting typing to false be blocking
        typingCoolDown?.cancel();
        typingCoolDown = null;
        room.setTyping(false);
        currentlyTyping = false;
      }
      // then cancel the old timeline
      // fixes bug with read reciepts and quick switching
      loadTimelineFuture = _getTimeline(eventContextId: room.fullyRead).onError(
        ErrorReporter(
          context,
          'Unable to load timeline after changing sending Client',
        ).onErrorCallback,
      );
  
      // then set the new sending client
      setState(() => sendingClient = c);
    }
  
    void setActiveClient(Client c) => setState(() {
          Matrix.of(context).setActiveClient(c);
        });
  
    Future<void> send() async {
      if (sendController.text.trim().isEmpty) return;
      _storeInputTimeoutTimer?.cancel();
      final prefs = await SharedPreferences.getInstance();
      prefs.remove('draft_$roomId');
      var parseCommands = true;
  
      final commandMatch = RegExp(r'^\/(\w+)').firstMatch(sendController.text);
      if (commandMatch != null &&
          !sendingClient.commands.keys.contains(commandMatch[1]!.toLowerCase())) {
        final l10n = L10n.of(context);
        final dialogResult = await showOkCancelAlertDialog(
          context: context,
          title: l10n.commandInvalid,
          message: l10n.commandMissing(commandMatch[0]!),
          okLabel: l10n.sendAsText,
          cancelLabel: l10n.cancel,
        );
        if (dialogResult == OkCancelResult.cancel) return;
        parseCommands = false;
      }
  
      // ignore: unawaited_futures
      room.sendTextEvent(
        sendController.text,
        inReplyTo: replyEvent,
        editEventId: editEvent?.eventId,
        parseCommands: parseCommands,
      );
      sendController.value = TextEditingValue(
        text: pendingText,
        selection: const TextSelection.collapsed(offset: 0),
      );
  
      setState(() {
        sendController.text = pendingText;
        _inputTextIsEmpty = pendingText.isEmpty;
        replyEvent = null;
        editEvent = null;
        pendingText = '';
      });
    }
  
    void sendFileAction({FileSelectorType type = FileSelectorType.any}) async {
      final files = await selectFiles(
        context,
        allowMultiple: true,
        type: type,
      );
      if (files.isEmpty) return;
      await showAdaptiveDialog(
        context: context,
        builder: (c) => SendFileDialog(
          files: files,
          room: room,
          outerContext: context,
        ),
      );
    }
  
    void sendImageFromClipBoard(Uint8List? image) async {
      if (image == null) return;
      await showAdaptiveDialog(
        context: context,
        builder: (c) => SendFileDialog(
          files: [XFile.fromData(image)],
          room: room,
          outerContext: context,
        ),
      );
    }
  
    void openCameraAction() async {
      // Make sure the textfield is unfocused before opening the camera
      FocusScope.of(context).requestFocus(FocusNode());
      final file = await ImagePicker().pickImage(source: ImageSource.camera);
      if (file == null) return;
  
      await showAdaptiveDialog(
        context: context,
        builder: (c) => SendFileDialog(
          files: [file],
          room: room,
          outerContext: context,
        ),
      );
    }
  
    void openVideoCameraAction() async {
      // Make sure the textfield is unfocused before opening the camera
      FocusScope.of(context).requestFocus(FocusNode());
      final file = await ImagePicker().pickVideo(
        source: ImageSource.camera,
        maxDuration: const Duration(minutes: 1),
      );
      if (file == null) return;
  
      await showAdaptiveDialog(
        context: context,
        builder: (c) => SendFileDialog(
          files: [file],
          room: room,
          outerContext: context,
        ),
      );
    }
  
    Future<void> onVoiceMessageSend(
      String path,
      int duration,
      List<int> waveform,
      String? fileName,
    ) async {
      final scaffoldMessenger = ScaffoldMessenger.of(context);
      final audioFile = XFile(path);
  
      final bytesResult = await showFutureLoadingDialog(
        context: context,
        future: audioFile.readAsBytes,
      );
      final bytes = bytesResult.result;
      if (bytes == null) return;
  
      final file = MatrixAudioFile(
        bytes: bytes,
        name: fileName ?? audioFile.path,
      );
  
      setState(() {
        replyEvent = null;
      });
      room.sendFileEvent(
        file,
        inReplyTo: replyEvent,
        extraContent: {
          'info': {
            ...file.info,
            'duration': duration,
          },
          'org.matrix.msc3245.voice': {},
          'org.matrix.msc1767.audio': {
            'duration': duration,
            'waveform': waveform,
          },
        },
      ).catchError((e) {
        scaffoldMessenger.showSnackBar(
          SnackBar(
            content: Text(
              (e as Object).toLocalizedString(context),
            ),
          ),
        );
        return null;
      });
      return;
    }
  
    void hideEmojiPicker() {
      setState(() => showEmojiPicker = false);
    }
  
    void emojiPickerAction() {
      if (showEmojiPicker) {
        inputFocus.requestFocus();
      } else {
        inputFocus.unfocus();
      }
      setState(() => showEmojiPicker = !showEmojiPicker);
    }
  
    void _inputFocusListener() {
      if (showEmojiPicker && inputFocus.hasFocus) {
        setState(() => showEmojiPicker = false);
      }
    }
  
    void sendLocationAction() async {
      await showAdaptiveDialog(
        context: context,
        builder: (c) => SendLocationDialog(room: room),
      );
    }
  
    String _getSelectedEventString() {
      var copyString = '';
      if (selectedEvents.length == 1) {
        return selectedEvents.first
            .getDisplayEvent(timeline!)
            .calcLocalizedBodyFallback(MatrixLocals(L10n.of(context)));
      }
      for (final event in selectedEvents) {
        if (copyString.isNotEmpty) copyString += '\n\n';
        copyString += event.getDisplayEvent(timeline!).calcLocalizedBodyFallback(
              MatrixLocals(L10n.of(context)),
              withSenderNamePrefix: true,
            );
      }
      return copyString;
    }
  
    void copyEventsAction() {
      Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
      setState(() {
        showEmojiPicker = false;
        selectedEvents.clear();
      });
    }
  
    void reportEventAction() async {
      final event = selectedEvents.single;
      final score = await showModalActionPopup<int>(
        context: context,
        title: L10n.of(context).reportMessage,
        message: L10n.of(context).howOffensiveIsThisContent,
        cancelLabel: L10n.of(context).cancel,
        actions: [
          AdaptiveModalAction(
            value: -100,
            label: L10n.of(context).extremeOffensive,
          ),
          AdaptiveModalAction(
            value: -50,
            label: L10n.of(context).offensive,
          ),
          AdaptiveModalAction(
            value: 0,
            label: L10n.of(context).inoffensive,
          ),
        ],
      );
      if (score == null) return;
      final reason = await showTextInputDialog(
        context: context,
        title: L10n.of(context).whyDoYouWantToReportThis,
        okLabel: L10n.of(context).ok,
        cancelLabel: L10n.of(context).cancel,
        hintText: L10n.of(context).reason,
      );
      if (reason == null || reason.isEmpty) return;
      final result = await showFutureLoadingDialog(
        context: context,
        future: () => Matrix.of(context).client.reportEvent(
              event.roomId!,
              event.eventId,
              reason: reason,
              score: score,
            ),
      );
      if (result.error != null) return;
      setState(() {
        showEmojiPicker = false;
        selectedEvents.clear();
      });
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(L10n.of(context).contentHasBeenReported)),
      );
    }
  
    void deleteErrorEventsAction() async {
      try {
        if (selectedEvents.any((event) => event.status != EventStatus.error)) {
          throw Exception(
            'Tried to delete failed to send events but one event is not failed to sent',
          );
        }
        for (final event in selectedEvents) {
          await event.cancelSend();
        }
        setState(selectedEvents.clear);
      } catch (e, s) {
        ErrorReporter(
          context,
          'Error while delete error events action',
        ).onErrorCallback(e, s);
      }
    }
  
    void redactEventsAction() async {
      final reasonInput = selectedEvents.any((event) => event.status.isSent)
          ? await showTextInputDialog(
              context: context,
              title: L10n.of(context).redactMessage,
              message: L10n.of(context).redactMessageDescription,
              isDestructive: true,
              hintText: L10n.of(context).optionalRedactReason,
              maxLength: 255,
              maxLines: 3,
              minLines: 1,
              okLabel: L10n.of(context).remove,
              cancelLabel: L10n.of(context).cancel,
            )
          : null;
      if (reasonInput == null) return;
      final reason = reasonInput.isEmpty ? null : reasonInput;
      await showFutureLoadingDialog(
        context: context,
        futureWithProgress: (onProgress) async {
          final count = selectedEvents.length;
          for (final (i, event) in selectedEvents.indexed) {
            onProgress(i / count);
            if (event.status.isSent) {
              if (event.canRedact) {
                await event.redactEvent(reason: reason);
              } else {
                final client = currentRoomBundle.firstWhere(
                  (cl) => selectedEvents.first.senderId == cl!.userID,
                  orElse: () => null,
                );
                if (client == null) {
                  return;
                }
                final room = client.getRoomById(roomId)!;
                await Event.fromJson(event.toJson(), room).redactEvent(
                  reason: reason,
                );
              }
            } else {
              await event.cancelSend();
            }
          }
        },
      );
      setState(() {
        showEmojiPicker = false;
        selectedEvents.clear();
      });
    }
  
    List<Client?> get currentRoomBundle {
      final clients = Matrix.of(context).currentBundle!;
      clients.removeWhere((c) => c!.getRoomById(roomId) == null);
      return clients;
    }
  
    bool get canRedactSelectedEvents {
      if (isArchived) return false;
      final clients = Matrix.of(context).currentBundle;
      for (final event in selectedEvents) {
        if (!event.status.isSent) return false;
        if (event.canRedact == false &&
            !(clients!.any((cl) => event.senderId == cl!.userID))) {
          return false;
        }
      }
      return true;
    }
  
    bool get canPinSelectedEvents {
      if (isArchived ||
          !room.canChangeStateEvent(EventTypes.RoomPinnedEvents) ||
          selectedEvents.length != 1 ||
          !selectedEvents.single.status.isSent) {
        return false;
      }
      return true;
    }
  
    bool get canEditSelectedEvents {
      if (isArchived ||
          selectedEvents.length != 1 ||
          !selectedEvents.first.status.isSent) {
        return false;
      }
      return currentRoomBundle
          .any((cl) => selectedEvents.first.senderId == cl!.userID);
    }
  
    void forwardEventsAction() async {
      if (selectedEvents.isEmpty) return;
      final timeline = this.timeline;
      if (timeline == null) return;
  
      final forwardEvents = List<Event>.from(selectedEvents)
          .map((event) => event.getDisplayEvent(timeline))
          .toList();
  
      await showScaffoldDialog(
        context: context,
        builder: (context) => ShareScaffoldDialog(
          items: forwardEvents
              .map((event) => ContentShareItem(event.content))
              .toList(),
        ),
      );
      if (!mounted) return;
      setState(() => selectedEvents.clear());
    }
  
    void sendAgainAction() {
      final event = selectedEvents.first;
      if (event.status.isError) {
        event.sendAgain();
      }
      final allEditEvents = event
          .aggregatedEvents(timeline!, RelationshipTypes.edit)
          .where((e) => e.status.isError);
      for (final e in allEditEvents) {
        e.sendAgain();
      }
      setState(() => selectedEvents.clear());
    }
  
    void replyAction({Event? replyTo}) {
      setState(() {
        replyEvent = replyTo ?? selectedEvents.first;
        selectedEvents.clear();
      });
      inputFocus.requestFocus();
    }
  
    void scrollToEventId(
      String eventId, {
      bool highlightEvent = true,
    }) async {
      final foundEvent =
          timeline!.events.firstWhereOrNull((event) => event.eventId == eventId);
  
      final eventIndex = foundEvent == null
          ? -1
          : timeline!.events
              .filterByVisibleInGui(exceptionEventId: eventId)
              .indexOf(foundEvent);
  
      if (eventIndex == -1) {
        setState(() {
          timeline = null;
          _scrolledUp = false;
          loadTimelineFuture = _getTimeline(eventContextId: eventId).onError(
            ErrorReporter(context, 'Unable to load timeline after scroll to ID')
                .onErrorCallback,
          );
        });
        await loadTimelineFuture;
        WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
          scrollToEventId(eventId);
        });
        return;
      }
      if (highlightEvent) {
        setState(() {
          scrollToEventIdMarker = eventId;
        });
      }
      await scrollController.scrollToIndex(
        eventIndex + 1,
        duration: FluffyThemes.animationDuration,
        preferPosition: AutoScrollPosition.middle,
      );
      _updateScrollController();
    }
  
    void scrollDown() async {
      if (!timeline!.allowNewEvent) {
        setState(() {
          timeline = null;
          _scrolledUp = false;
          loadTimelineFuture = _getTimeline().onError(
            ErrorReporter(context, 'Unable to load timeline after scroll down')
                .onErrorCallback,
          );
        });
        await loadTimelineFuture;
      }
      scrollController.jumpTo(0);
    }
  
    void onEmojiSelected(_, Emoji? emoji) {
      typeEmoji(emoji);
      onInputBarChanged(sendController.text);
    }
  
    void typeEmoji(Emoji? emoji) {
      if (emoji == null) return;
      final text = sendController.text;
      final selection = sendController.selection;
      final newText = sendController.text.isEmpty
          ? emoji.emoji
          : text.replaceRange(selection.start, selection.end, emoji.emoji);
      sendController.value = TextEditingValue(
        text: newText,
        selection: TextSelection.collapsed(
          // don't forget an UTF-8 combined emoji might have a length > 1
          offset: selection.baseOffset + emoji.emoji.length,
        ),
      );
    }
  
    void emojiPickerBackspace() {
      sendController
        ..text = sendController.text.characters.skipLast(1).toString()
        ..selection = TextSelection.fromPosition(
          TextPosition(offset: sendController.text.length),
        );
    }
  
    void clearSelectedEvents() => setState(() {
          selectedEvents.clear();
          showEmojiPicker = false;
        });
  
    void clearSingleSelectedEvent() {
      if (selectedEvents.length <= 1) {
        clearSelectedEvents();
      }
    }
  
    void editSelectedEventAction() {
      final client = currentRoomBundle.firstWhere(
        (cl) => selectedEvents.first.senderId == cl!.userID,
        orElse: () => null,
      );
      if (client == null) {
        return;
      }
      setSendingClient(client);
      setState(() {
        pendingText = sendController.text;
        editEvent = selectedEvents.first;
        sendController.text =
            editEvent!.getDisplayEvent(timeline!).calcLocalizedBodyFallback(
                  MatrixLocals(L10n.of(context)),
                  withSenderNamePrefix: false,
                  hideReply: true,
                );
        selectedEvents.clear();
      });
      inputFocus.requestFocus();
    }
  
    void goToNewRoomAction() async {
      final newRoomId = room
          .getState(EventTypes.RoomTombstone)!
          .parsedTombstoneContent
          .replacementRoom;
      final result = await showFutureLoadingDialog(
        context: context,
        future: () => room.client.joinRoom(
          room
              .getState(EventTypes.RoomTombstone)!
              .parsedTombstoneContent
              .replacementRoom,
          via: [newRoomId.domain!],
        ),
      );
      if (result.error != null) return;
      if (!mounted) return;
      context.go('/rooms/${result.result!}');
  
      await showFutureLoadingDialog(
        context: context,
        future: room.leave,
      );
    }
  
    void onSelectMessage(Event event) {
      if (!event.redacted) {
        if (selectedEvents.contains(event)) {
          setState(
            () => selectedEvents.remove(event),
          );
        } else {
          setState(
            () => selectedEvents.add(event),
          );
        }
        selectedEvents.sort(
          (a, b) => a.originServerTs.compareTo(b.originServerTs),
        );
      }
    }
  
    int? findChildIndexCallback(Key key, Map<String, int> thisEventsKeyMap) {
      // this method is called very often. As such, it has to be optimized for speed.
      if (key is! ValueKey) {
        return null;
      }
      final eventId = key.value;
      if (eventId is! String) {
        return null;
      }
      // first fetch the last index the event was at
      final index = thisEventsKeyMap[eventId];
      if (index == null) {
        return null;
      }
      // we need to +1 as 0 is the typing thing at the bottom
      return index + 1;
    }
  
    void onInputBarSubmitted(_) {
      send();
      FocusScope.of(context).requestFocus(inputFocus);
    }
  
    void onAddPopupMenuButtonSelected(String choice) {
      room.client.getConfig(); // Preload server file configuration.
  
      if (choice == 'file') {
        sendFileAction();
      }
      if (choice == 'image') {
        sendFileAction(type: FileSelectorType.images);
      }
      if (choice == 'video') {
        sendFileAction(type: FileSelectorType.videos);
      }
      if (choice == 'camera') {
        openCameraAction();
      }
      if (choice == 'camera-video') {
        openVideoCameraAction();
      }
      if (choice == 'location') {
        sendLocationAction();
      }
    }
  
    unpinEvent(String eventId) async {
      final response = await showOkCancelAlertDialog(
        context: context,
        title: L10n.of(context).unpin,
        message: L10n.of(context).confirmEventUnpin,
        okLabel: L10n.of(context).unpin,
        cancelLabel: L10n.of(context).cancel,
      );
      if (response == OkCancelResult.ok) {
        final events = room.pinnedEventIds
          ..removeWhere((oldEvent) => oldEvent == eventId);
        showFutureLoadingDialog(
          context: context,
          future: () => room.setPinnedEvents(events),
        );
      }
    }
  
    void pinEvent() {
      final pinnedEventIds = room.pinnedEventIds;
      final selectedEventIds = selectedEvents.map((e) => e.eventId).toSet();
      final unpin = selectedEventIds.length == 1 &&
          pinnedEventIds.contains(selectedEventIds.single);
      if (unpin) {
        pinnedEventIds.removeWhere(selectedEventIds.contains);
      } else {
        pinnedEventIds.addAll(selectedEventIds);
      }
      showFutureLoadingDialog(
        context: context,
        future: () => room.setPinnedEvents(pinnedEventIds),
      );
    }
  
    Timer? _storeInputTimeoutTimer;
    static const Duration _storeInputTimeout = Duration(milliseconds: 500);
  
    void onInputBarChanged(String text) {
      if (_inputTextIsEmpty != text.isEmpty) {
        setState(() {
          _inputTextIsEmpty = text.isEmpty;
        });
      }
  
      _storeInputTimeoutTimer?.cancel();
      _storeInputTimeoutTimer = Timer(_storeInputTimeout, () async {
        final prefs = await SharedPreferences.getInstance();
        await prefs.setString('draft_$roomId', text);
      });
      if (text.endsWith(' ') && Matrix.of(context).hasComplexBundles) {
        final clients = currentRoomBundle;
        for (final client in clients) {
          final prefix = client!.sendPrefix;
          if ((prefix.isNotEmpty) &&
              text.toLowerCase() == '${prefix.toLowerCase()} ') {
            setSendingClient(client);
            setState(() {
              sendController.clear();
            });
            return;
          }
        }
      }
      if (AppConfig.sendTypingNotifications) {
        typingCoolDown?.cancel();
        typingCoolDown = Timer(const Duration(seconds: 2), () {
          typingCoolDown = null;
          currentlyTyping = false;
          room.setTyping(false);
        });
        typingTimeout ??= Timer(const Duration(seconds: 30), () {
          typingTimeout = null;
          currentlyTyping = false;
        });
        if (!currentlyTyping) {
          currentlyTyping = true;
          room.setTyping(
            true,
            timeout: const Duration(seconds: 30).inMilliseconds,
          );
        }
      }
    }
  
    bool _inputTextIsEmpty = true;
  
    bool get isArchived =>
        {Membership.leave, Membership.ban}.contains(room.membership);
  
    void showEventInfo([Event? event]) =>
        (event ?? selectedEvents.single).showInfoDialog(context);
  
    void onPhoneButtonTap() async {
      // VoIP required Android SDK 21
      if (PlatformInfos.isAndroid) {
        DeviceInfoPlugin().androidInfo.then((value) {
          if (value.version.sdkInt < 21) {
            Navigator.pop(context);
            showOkAlertDialog(
              context: context,
              title: L10n.of(context).unsupportedAndroidVersion,
              message: L10n.of(context).unsupportedAndroidVersionLong,
              okLabel: L10n.of(context).close,
            );
          }
        });
      }
      final callType = await showModalActionPopup<CallType>(
        context: context,
        title: L10n.of(context).warning,
        message: L10n.of(context).videoCallsBetaWarning,
        cancelLabel: L10n.of(context).cancel,
        actions: [
          AdaptiveModalAction(
            label: L10n.of(context).voiceCall,
            icon: const Icon(Icons.phone_outlined),
            value: CallType.kVoice,
          ),
          AdaptiveModalAction(
            label: L10n.of(context).videoCall,
            icon: const Icon(Icons.video_call_outlined),
            value: CallType.kVideo,
          ),
        ],
      );
      if (callType == null) return;
  
      final voipPlugin = Matrix.of(context).voipPlugin;
      try {
        await voipPlugin!.voip.inviteToCall(room, callType);
      } catch (e) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(e.toLocalizedString(context))),
        );
      }
    }
  
    void cancelReplyEventAction() => setState(() {
          if (editEvent != null) {
            sendController.text = pendingText;
            pendingText = '';
          }
          replyEvent = null;
          editEvent = null;
        });
  
    late final ValueNotifier<bool> _displayChatDetailsColumn;
  
    void toggleDisplayChatDetailsColumn() async {
      await AppSettings.displayChatDetailsColumn.setItem(
        Matrix.of(context).store,
        !_displayChatDetailsColumn.value,
      );
      _displayChatDetailsColumn.value = !_displayChatDetailsColumn.value;
    }
  
    @override
    Widget build(BuildContext context) {
      final theme = Theme.of(context);
      return Row(
        children: [
          Expanded(
            child: ChatView(this),
          ),
          ValueListenableBuilder(
            valueListenable: _displayChatDetailsColumn,
            builder: (context, displayChatDetailsColumn, _) =>
                !FluffyThemes.isThreeColumnMode(context) ||
                        room.membership != Membership.join ||
                        !displayChatDetailsColumn
                    ? const SizedBox(
                        height: double.infinity,
                        width: 0,
                      )
                    : Container(
                        width: FluffyThemes.columnWidth,
                        clipBehavior: Clip.hardEdge,
                        decoration: BoxDecoration(
                          border: Border(
                            left: BorderSide(
                              width: 1,
                              color: theme.dividerColor,
                            ),
                          ),
                        ),
                        child: ChatDetails(
                          roomId: roomId,
                          embeddedCloseButton: IconButton(
                            icon: const Icon(Icons.close),
                            onPressed: toggleDisplayChatDetailsColumn,
                          ),
                        ),
                      ),
          ),
        ],
      );
    }
  }
  