import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:adaptive_dialog/adaptive_dialog.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:file_picker/file_picker.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:matrix/matrix.dart'; import 'package:record/record.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat_view.dart'; import 'package:fluffychat/pages/chat/event_info_dialog.dart'; import 'package:fluffychat/pages/chat/recording_dialog.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/app_lock.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/account_bundles.dart'; import '../../utils/localized_exception_extension.dart'; import '../../utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'send_file_dialog.dart'; import 'send_location_dialog.dart'; import 'sticker_picker_dialog.dart'; class ChatPage extends StatelessWidget { final String roomId; final String? shareText; const ChatPage({ super.key, required this.roomId, this.shareText, }); @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 Row( children: [ Expanded( child: ChatPageWithRoom( key: Key('chat_page_$roomId'), room: room, shareText: shareText, ), ), if (FluffyThemes.isThreeColumnMode(context) && room.membership == Membership.join) Container( width: FluffyThemes.columnWidth, clipBehavior: Clip.hardEdge, decoration: BoxDecoration( border: Border( left: BorderSide( width: 1, color: Theme.of(context).dividerColor, ), ), ), child: ChatDetails(roomId: roomId), ), ], ); } } class ChatPageWithRoom extends StatefulWidget { final Room room; final String? shareText; const ChatPageWithRoom({ super.key, required this.room, this.shareText, }); @override ChatController createState() => ChatController(); } class ChatController extends State with WidgetsBindingObserver { Room get room => sendingClient.getRoomById(roomId) ?? widget.room; late Client sendingClient; Timeline? timeline; String? readMarkerEventId; String get roomId => widget.room.id; final AutoScrollController scrollController = AutoScrollController(); FocusNode inputFocus = FocusNode(); 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; final result = await showFutureLoadingDialog( context: context, future: () async { final clientConfig = await room.client.getConfig(); final maxUploadSize = clientConfig.mUploadSize ?? 100 * 1024 * 1024; final matrixFiles = await Future.wait( details.files.map( (xfile) async { final length = await xfile.length(); if (length > maxUploadSize) { throw FileTooBigMatrixException(length, maxUploadSize); } return MatrixFile( bytes: await xfile.readAsBytes(), name: xfile.name, ); }, ), ); return matrixFiles; }, ); final matrixFiles = result.result; if (matrixFiles == null || matrixFiles.isEmpty) return; await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( files: matrixFiles, room: room, ), ); } 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 selectedEvents = []; final Set 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'); } EmojiPickerType emojiPickerType = EmojiPickerType.keyboard; void requestHistory([_]) async { if (!timeline!.canRequestHistory) return; Logs().v('Requesting history...'); await timeline!.requestHistory(historyCount: _loadHistoryCount); } void requestFuture() async { final timeline = this.timeline; if (timeline == null) return; if (!timeline.canRequestFuture) 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); } if (scrollController.position.pixels == 0 || scrollController.position.pixels == 64) { requestFuture(); } } void _loadDraft() async { final prefs = await SharedPreferences.getInstance(); final draft = widget.shareText ?? prefs.getString('draft_$roomId'); if (draft != null && draft.isNotEmpty) { sendController.text = draft; } } @override void initState() { scrollController.addListener(_updateScrollController); inputFocus.addListener(_inputFocusListener); _loadDraft(); super.initState(); sendingClient = Matrix.of(context).client; _tryLoadTimeline(); } void _tryLoadTimeline() async { loadTimelineFuture = _getTimeline(); try { await loadTimelineFuture; final fullyRead = room.fullyRead; if (fullyRead.isEmpty) return; if (timeline!.events.any((event) => event.eventId == fullyRead)) { Logs().v('Scroll up to visible event', fullyRead); setReadMarker(); return; } if (!mounted) return; _showScrollUpMaterialBanner(fullyRead); } 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; setState(() {}); } Future? loadTimelineFuture; int? animateInEventIndex; void onInsert(int i) { // setState will be called by updateView() anyway animateInEventIndex = i; } Future _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 = 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); // when the scroll controller is attached we want to scroll to an event id, if specified // and update the scroll controller...which will trigger a request history, if the // "load more" button is visible on the screen SchedulerBinding.instance.addPostFrameCallback((_) async { if (mounted) { final event = GoRouterState.of(context).uri.queryParameters['event']; if (event != null) { scrollToEventId(event); } } }); return; } String? scrollToEventIdMarker; @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state != AppLifecycleState.resumed) return; if (!_scrolledUp) return; setReadMarker(); } Future? _setReadMarkerFuture; void setReadMarker({String? eventId}) { if (_setReadMarkerFuture != null) return; if (scrollUpBannerEventId != null) return; if (eventId == null && !room.hasNewMessages && room.notificationCount == 0) { return; } if (!Matrix.of(context).webHasFocus) 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); 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 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() async { final result = await AppLock.of(context).pauseWhile( FilePicker.platform.pickFiles( allowMultiple: false, withData: true, ), ); if (result == null || result.files.isEmpty) return; await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( files: result.files .map( (xfile) => MatrixFile( bytes: xfile.bytes!, name: xfile.name, ).detectFileType, ) .toList(), room: room, ), ); } void sendImageFromClipBoard(Uint8List? image) async { await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( files: [ MatrixFile( bytes: image!, name: "image from Clipboard", ).detectFileType, ], room: room, ), ); } void sendImageAction() async { final result = await AppLock.of(context).pauseWhile( FilePicker.platform.pickFiles( type: FileType.image, withData: true, allowMultiple: false, ), ); if (result == null || result.files.isEmpty) return; await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( files: result.files .map( (xfile) => MatrixFile( bytes: xfile.bytes!, name: xfile.name, ).detectFileType, ) .toList(), room: room, ), ); } 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; final bytes = await file.readAsBytes(); await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( files: [ MatrixImageFile( bytes: bytes, name: file.path, ), ], room: room, ), ); } 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; final bytes = await file.readAsBytes(); await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( files: [ MatrixVideoFile( bytes: bytes, name: file.path, ), ], room: room, ), ); } void sendStickerAction() async { final sticker = await showAdaptiveBottomSheet( context: context, builder: (c) => StickerPickerDialog(room: room), ); if (sticker == null) return; final eventContent = { 'body': sticker.body, if (sticker.info != null) 'info': sticker.info, 'url': sticker.url.toString(), }; // send the sticker await room.sendEvent( eventContent, type: EventTypes.Sticker, ); } void voiceMessageAction() async { final scaffoldMessenger = ScaffoldMessenger.of(context); if (PlatformInfos.isAndroid) { final info = await DeviceInfoPlugin().androidInfo; if (info.version.sdkInt < 19) { showOkAlertDialog( context: context, title: L10n.of(context)!.unsupportedAndroidVersion, message: L10n.of(context)!.unsupportedAndroidVersionLong, okLabel: L10n.of(context)!.close, ); return; } } if (await Record().hasPermission() == false) return; final result = await showDialog( context: context, barrierDismissible: false, builder: (c) => const RecordingDialog(), ); if (result == null) return; final audioFile = File(result.path); final file = MatrixAudioFile( bytes: audioFile.readAsBytesSync(), name: audioFile.path, ); await room.sendFileEvent( file, inReplyTo: replyEvent, extraContent: { 'info': { ...file.info, 'duration': result.duration, }, 'org.matrix.msc3245.voice': {}, 'org.matrix.msc1767.audio': { 'duration': result.duration, 'waveform': result.waveform, }, }, ).catchError((e) { scaffoldMessenger.showSnackBar( SnackBar( content: Text( (e as Object).toLocalizedString(context), ), ), ); return null; }); setState(() { replyEvent = null; }); } void emojiPickerAction() { if (showEmojiPicker) { inputFocus.requestFocus(); } else { inputFocus.unfocus(); } emojiPickerType = EmojiPickerType.keyboard; setState(() => showEmojiPicker = !showEmojiPicker); } void _inputFocusListener() { if (showEmojiPicker && inputFocus.hasFocus) { emojiPickerType = EmojiPickerType.keyboard; 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 showConfirmationDialog( context: context, title: L10n.of(context)!.reportMessage, message: L10n.of(context)!.howOffensiveIsThisContent, cancelLabel: L10n.of(context)!.cancel, okLabel: L10n.of(context)!.ok, actions: [ AlertDialogAction( key: -100, label: L10n.of(context)!.extremeOffensive, ), AlertDialogAction( key: -50, label: L10n.of(context)!.offensive, ), AlertDialogAction( key: 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, textFields: [DialogTextField(hintText: L10n.of(context)!.reason)], ); if (reason == null || reason.single.isEmpty) return; final result = await showFutureLoadingDialog( context: context, future: () => Matrix.of(context).client.reportContent( event.roomId!, event.eventId, reason: reason.single, 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.remove(); } 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, isDestructiveAction: true, textFields: [ DialogTextField( hintText: L10n.of(context)!.optionalRedactReason, ), ], okLabel: L10n.of(context)!.remove, cancelLabel: L10n.of(context)!.cancel, ) : []; if (reasonInput == null) return; final reason = reasonInput.single.isEmpty ? null : reasonInput.single; for (final event in selectedEvents) { await showFutureLoadingDialog( context: context, future: () async { 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.remove(); } }, ); } setState(() { showEmojiPicker = false; selectedEvents.clear(); }); } List 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.length == 1) { Matrix.of(context).shareContent = selectedEvents.first.getDisplayEvent(timeline!).content; } else { Matrix.of(context).shareContent = { 'msgtype': 'm.text', 'body': _getSelectedEventString(), }; } setState(() => selectedEvents.clear()); context.go('/rooms'); } 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) async { final eventIndex = timeline!.events.indexWhere((e) => e.eventId == eventId); 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; } setState(() { scrollToEventIdMarker = eventId; }); await scrollController.scrollToIndex( eventIndex, 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; setReadMarker(); } scrollController.jumpTo(0); } void onEmojiSelected(_, Emoji? emoji) { switch (emojiPickerType) { case EmojiPickerType.reaction: senEmojiReaction(emoji); break; case EmojiPickerType.keyboard: typeEmoji(emoji); onInputBarChanged(sendController.text); break; } } void senEmojiReaction(Emoji? emoji) { setState(() => showEmojiPicker = false); if (emoji == null) return; // make sure we don't send the same emoji twice if (_allReactionEvents.any( (e) => e.content.tryGetMap('m.relates_to')?['key'] == emoji.emoji, )) { return; } return sendEmojiAction(emoji.emoji); } 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, ), ); } late Iterable _allReactionEvents; void emojiPickerBackspace() { switch (emojiPickerType) { case EmojiPickerType.reaction: setState(() => showEmojiPicker = false); break; case EmojiPickerType.keyboard: sendController ..text = sendController.text.characters.skipLast(1).toString() ..selection = TextSelection.fromPosition( TextPosition(offset: sendController.text.length), ); break; } } void pickEmojiReactionAction(Iterable allReactionEvents) async { _allReactionEvents = allReactionEvents; emojiPickerType = EmojiPickerType.reaction; setState(() => showEmojiPicker = true); } void sendEmojiAction(String? emoji) async { final events = List.from(selectedEvents); setState(() => selectedEvents.clear()); for (final event in events) { await room.sendReaction( event.eventId, emoji!, ); } } 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 { if (OkCancelResult.ok != await showOkCancelAlertDialog( context: context, title: L10n.of(context)!.goToTheNewRoom, message: room .getState(EventTypes.RoomTombstone)! .parsedTombstoneContent .body, okLabel: L10n.of(context)!.ok, cancelLabel: L10n.of(context)!.cancel, )) { return; } final result = await showFutureLoadingDialog( context: context, future: () => room.client.joinRoom( room .getState(EventTypes.RoomTombstone)! .parsedTombstoneContent .replacementRoom, ), ); await showFutureLoadingDialog( context: context, future: room.leave, ); if (result.error == null) { context.go('/rooms/${result.result!}'); } } 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 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) { if (choice == 'file') { sendFileAction(); } if (choice == 'image') { sendImageAction(); } if (choice == 'camera') { openCameraAction(); } if (choice == 'camera-video') { openVideoCameraAction(); } if (choice == 'sticker') { sendStickerAction(); } 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) { setReadMarker(); 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 showModalActionSheet( context: context, title: L10n.of(context)!.warning, message: L10n.of(context)!.videoCallsBetaWarning, cancelLabel: L10n.of(context)!.cancel, actions: [ SheetAction( label: L10n.of(context)!.voiceCall, icon: Icons.phone_outlined, key: CallType.kVoice, ), SheetAction( label: L10n.of(context)!.videoCall, icon: Icons.video_call_outlined, key: CallType.kVideo, ), ], ); if (callType == null) return; final success = await showFutureLoadingDialog( context: context, future: () => Matrix.of(context).voipPlugin!.voip.requestTurnServerCredentials(), ); if (success.result != null) { final voipPlugin = Matrix.of(context).voipPlugin; try { await voipPlugin!.voip.inviteToCall(room.id, callType); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toLocalizedString(context))), ); } } else { await showOkAlertDialog( context: context, title: L10n.of(context)!.unavailable, okLabel: L10n.of(context)!.next, ); } } void cancelReplyEventAction() => setState(() { if (editEvent != null) { sendController.text = pendingText; pendingText = ''; } replyEvent = null; editEvent = null; }); @override Widget build(BuildContext context) => ChatView(this); } enum EmojiPickerType { reaction, keyboard }