import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:cwtch/cwtch/cwtch.dart'; import 'package:cwtch/cwtch_icons_icons.dart'; import 'package:cwtch/models/appstate.dart'; import 'package:cwtch/models/chatmessage.dart'; import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; import 'package:cwtch/models/messagecache.dart'; import 'package:cwtch/models/messages/quotedmessage.dart'; import 'package:cwtch/models/profile.dart'; import 'package:cwtch/themes/opaque.dart'; import 'package:cwtch/third_party/linkify/flutter_linkify.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:cwtch/widgets/messageloadingbubble.dart'; import 'package:cwtch/widgets/profileimage.dart'; import 'package:cwtch/controllers/filesharing.dart' as filesharing; import 'package:cwtch/widgets/staticmessagebubble.dart'; import 'package:flutter/material.dart'; import 'package:cwtch/views/peersettingsview.dart'; import 'package:cwtch/widgets/DropdownContacts.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../config.dart'; import '../constants.dart'; import '../main.dart'; import '../settings.dart'; import '../widgets/messagelist.dart'; import 'filesharingview.dart'; import 'groupsettingsview.dart'; class MessageView extends StatefulWidget { @override _MessageViewState createState() => _MessageViewState(); } class _MessageViewState extends State { final focusNode = FocusNode(); int selectedContact = -1; ItemPositionsListener scrollListener = ItemPositionsListener.create(); File? imagePreview; bool showDown = false; bool showPreview = false; final scaffoldKey = GlobalKey(); // <---- Another instance variable @override void initState() { scrollListener.itemPositions.addListener(() { if (scrollListener.itemPositions.value.length != 0 && Provider.of(context, listen: false).unreadMessagesBelow == true && scrollListener.itemPositions.value.any((element) => element.index == 0)) { Provider.of(context, listen: false).initialScrollIndex = 0; Provider.of(context, listen: false).unreadMessagesBelow = false; } if (scrollListener.itemPositions.value.length != 0 && !scrollListener.itemPositions.value.any((element) => element.index == 0)) { showDown = true; } else { showDown = false; } }); super.initState(); } @override void didChangeDependencies() { var appState = Provider.of(context, listen: false); // using "8" because "# of messages that fit on one screen" isnt trivial to calculate at this point if (appState.initialScrollIndex > 4 && appState.unreadMessagesBelow == false) { WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((timeStamp) { appState.unreadMessagesBelow = true; }); } super.didChangeDependencies(); } @override void dispose() { focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // After leaving a conversation the selected conversation is set to null... if (Provider.of(context, listen: false).profileOnion == "") { return Container(color: Provider.of(context).theme.backgroundMainColor, child: Center(child: Text(AppLocalizations.of(context)!.addContactFirst))); } var showMessageFormattingPreview = Provider.of(context).isExperimentEnabled(FormattingExperiment); var showFileSharing = Provider.of(context).isExperimentEnabled(FileSharingExperiment); var appBarButtons = []; if (showFileSharing) { appBarButtons.add(IconButton( splashRadius: Material.defaultSplashRadius / 2, icon: Icon(CwtchIcons.manage_files), tooltip: AppLocalizations.of(context)!.manageSharedFiles, onPressed: _pushFileSharingSettings)); } if (Provider.of(context, listen: false).isOnline()) { appBarButtons.add(IconButton( splashRadius: Material.defaultSplashRadius / 2, icon: Icon(CwtchIcons.disconnect_from_contact), tooltip: AppLocalizations.of(context)!.contactDisconnect, onPressed: () { if (Provider.of(context, listen: false).isGroup) { Provider.of(context, listen: false) .cwtch .DisconnectFromServer(Provider.of(context, listen: false).onion, Provider.of(context, listen: false).server!); } else { Provider.of(context, listen: false) .cwtch .DisconnectFromPeer(Provider.of(context, listen: false).onion, Provider.of(context, listen: false).onion); } // reset the disconnect button to allow for immediate connection... Provider.of(context, listen: false).lastRetryTime = DateTime.now().subtract(Duration(minutes: 2)); Provider.of(context, listen: false).contactEvents.add(ContactEvent("Disconnect from Peer")); })); } var profile = Provider.of(context, listen: false).profileOnion; var conversation = Provider.of(context, listen: false).identifier; if (Provider.of(context, listen: false).cwtch.IsBlodeuweddSupported() && Provider.of(context).isExperimentEnabled(BlodeuweddExperiment)) { appBarButtons.add(IconButton( splashRadius: Material.defaultSplashRadius / 2, icon: Icon(Icons.summarize), tooltip: AppLocalizations.of(context)!.blodeuweddSummarize, onPressed: () async { Provider.of(context, listen: false).summary = ""; Provider.of(context, listen: false).updateSummaryEvent(""); Provider.of(context, listen: false).cwtch.SummarizeConversation(profile, conversation); _summarizeConversation(context, Provider.of(context, listen: false), Provider.of(context, listen: false)); })); } if (Provider.of(context).isOnline()) { if (showFileSharing) { appBarButtons.add(IconButton( splashRadius: Material.defaultSplashRadius / 2, icon: Icon(CwtchIcons.attached_file_3, size: 26, color: Provider.of(context).theme.mainTextColor), tooltip: AppLocalizations.of(context)!.tooltipSendFile, onPressed: Provider.of(context).disableFilePicker ? null : () { imagePreview = null; filesharing.showFilePicker(context, MaxGeneralFileSharingSize, (File file) { _confirmFileSend(context, file.path); }, () { final snackBar = SnackBar( content: Text(AppLocalizations.of(context)!.msgFileTooBig), duration: Duration(seconds: 4), ); ScaffoldMessenger.of(context).showSnackBar(snackBar); }, () {}); }, )); } appBarButtons.add(IconButton( splashRadius: Material.defaultSplashRadius / 2, icon: Icon(CwtchIcons.send_invite, size: 24), tooltip: AppLocalizations.of(context)!.sendInvite, onPressed: () { _modalSendInvitation(context); })); } appBarButtons.add(IconButton( splashRadius: Material.defaultSplashRadius / 2, icon: Provider.of(context, listen: false).isGroup == true ? Icon(CwtchIcons.group_settings_24px) : Icon(CwtchIcons.peer_settings_24px), tooltip: AppLocalizations.of(context)!.conversationSettings, onPressed: _pushContactSettings)); var appState = Provider.of(context); return PopScope( onPopInvoked: _onWillPop, child: Scaffold( backgroundColor: Provider.of(context).theme.backgroundMainColor, floatingActionButton: showDown ? FloatingActionButton( // heroTags need to be unique per screen (important when we pop up and down)... heroTag: "popDown" + Provider.of(context, listen: false).onion, child: Icon(Icons.arrow_downward, color: Provider.of(context).current().defaultButtonTextColor), onPressed: () { Provider.of(context, listen: false).initialScrollIndex = 0; Provider.of(context, listen: false).unreadMessagesBelow = false; Provider.of(context, listen: false).messageScrollController.scrollTo(index: 0, duration: Duration(milliseconds: 600)); }) : null, appBar: AppBar( // setting leading(Width) to null makes it do the default behaviour; container() hides it leadingWidth: Provider.of(context).uiColumns(appState.isLandscape(context)).length > 1 ? 0 : null, leading: Provider.of(context).uiColumns(appState.isLandscape(context)).length > 1 ? Container( padding: EdgeInsets.zero, margin: EdgeInsets.zero, width: 0, height: 0, ) : null, title: Row(children: [ ProfileImage( imagePath: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) ? Provider.of(context).imagePath : Provider.of(context).defaultImagePath, diameter: 42, border: Provider.of(context).getBorderColor(Provider.of(context).theme), badgeTextColor: Colors.red, badgeColor: Provider.of(context).theme.portraitContactBadgeColor, badgeIcon: Provider.of(context).isGroup ? (Tooltip( message: Provider.of(context).isOnline() ? Provider.of(context).antispamTickets == 0 ? AppLocalizations.of(context)!.acquiringTicketsFromServer : AppLocalizations.of(context)!.acquiredTicketsFromServer : AppLocalizations.of(context)!.serverConnectivityDisconnected, child: Provider.of(context).isOnline() ? Provider.of(context).antispamTickets == 0 ? Icon( CwtchIcons.anti_spam_3, size: 14.0, semanticLabel: AppLocalizations.of(context)!.acquiringTicketsFromServer, color: Provider.of(context).theme.portraitContactBadgeTextColor, ) : Icon( CwtchIcons.anti_spam_2, color: Provider.of(context).theme.portraitContactBadgeTextColor, size: 14.0, ) : Icon( CwtchIcons.onion_off, color: Provider.of(context).theme.portraitContactBadgeTextColor, size: 14.0, ))) : null), SizedBox( width: 10, ), Expanded( child: Container( height: 42, clipBehavior: Clip.hardEdge, decoration: BoxDecoration(), child: Align( alignment: Alignment.centerLeft, child: Text( Provider.of(context).augmentedNickname(context), style: TextStyle(fontFamily: "Inter", fontWeight: FontWeight.bold, fontSize: 14.0 * Provider.of(context).fontScaling), overflow: TextOverflow.clip, maxLines: 1, )))) ]), actions: appBarButtons, ), body: Padding( padding: EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 164.0), child: MessageList( scrollListener, )), bottomSheet: showPreview && showMessageFormattingPreview ? _buildPreviewBox() : _buildComposeBox(context), )); } Future _onWillPop(popd) async { Provider.of(context, listen: false).unreadMessages = 0; var previouslySelected = Provider.of(context, listen: false).selectedConversation; if (previouslySelected != null) { Provider.of(context, listen: false).contactList.getContact(previouslySelected)!.unselected(); } Provider.of(context, listen: false).selectedConversation = null; return true; } void _pushFileSharingSettings() { var profileInfoState = Provider.of(context, listen: false); var contactInfoState = Provider.of(context, listen: false); Navigator.of(context).push( PageRouteBuilder( pageBuilder: (builderContext, a1, a2) { return MultiProvider( providers: [ChangeNotifierProvider.value(value: profileInfoState), ChangeNotifierProvider.value(value: contactInfoState)], child: FileSharingView(), ); }, transitionsBuilder: (c, anim, a2, child) => FadeTransition(opacity: anim, child: child), transitionDuration: Duration(milliseconds: 200), ), ); } void _pushContactSettings() { var profileInfoState = Provider.of(context, listen: false); var contactInfoState = Provider.of(context, listen: false); if (Provider.of(context, listen: false).isGroup == true) { Navigator.of(context).push( PageRouteBuilder( pageBuilder: (builderContext, a1, a2) { return MultiProvider( providers: [ChangeNotifierProvider.value(value: profileInfoState), ChangeNotifierProvider.value(value: contactInfoState)], child: GroupSettingsView(), ); }, transitionsBuilder: (c, anim, a2, child) => FadeTransition(opacity: anim, child: child), transitionDuration: Duration(milliseconds: 200), ), ); } else { Navigator.of(context).push( PageRouteBuilder( pageBuilder: (builderContext, a1, a2) { return MultiProvider( providers: [ChangeNotifierProvider.value(value: profileInfoState), ChangeNotifierProvider.value(value: contactInfoState)], child: PeerSettingsView(), ); }, transitionsBuilder: (c, anim, a2, child) => FadeTransition(opacity: anim, child: child), transitionDuration: Duration(milliseconds: 200), ), ); } } // todo: legacy groups currently have restricted message // size because of the additional wrapping end encoding // hybrid groups should allow these numbers to be the same. static const P2PMessageLengthMax = 7000; static const GroupMessageLengthMax = 1600; void _sendMessage([String? ignoredParam]) { // Do this after we trim to preserve enter-behaviour... bool cannotSend = Provider.of(context, listen: false).canSend() == false; bool isGroup = Provider.of(context, listen: false).isGroup; if (cannotSend) { return; } var attachedInvite = Provider.of(context, listen: false).messageDraft.getInviteHandle(); if (attachedInvite != null) { this._sendInvitation(attachedInvite); } // Trim message var messageText = Provider.of(context, listen: false).messageDraft.messageText ?? ""; final messageWithoutNewLine = messageText.trimRight(); // peers and groups currently have different length constraints (servers can store less)... var actualMessageLength = messageText.length; var lengthOk = (isGroup && actualMessageLength < GroupMessageLengthMax) || actualMessageLength <= P2PMessageLengthMax; if (messageWithoutNewLine.isNotEmpty && lengthOk) { if (Provider.of(context, listen: false).selectedConversation != null && Provider.of(context, listen: false).messageDraft.getQuotedMessage() != null) { var conversationId = Provider.of(context, listen: false).selectedConversation!; MessageCache? cache = Provider.of(context, listen: false).contactList.getContact(conversationId)?.messageCache; ById(Provider.of(context, listen: false).messageDraft.getQuotedMessage()!.index) .get(Provider.of(context, listen: false).cwtch, Provider.of(context, listen: false).selectedProfile!, conversationId, cache!) .then((MessageInfo? data) { try { var bytes1 = utf8.encode(data!.metadata.senderHandle + data.wrapper); var digest1 = sha256.convert(bytes1); var contentHash = base64Encode(digest1.bytes); var quotedMessage = jsonEncode(QuotedMessageStructure(contentHash, messageWithoutNewLine)); ChatMessage cm = new ChatMessage(o: QuotedMessageOverlay, d: quotedMessage); Provider.of(context, listen: false) .cwtch .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, jsonEncode(cm)) .then(_sendMessageHandler); } catch (e) { EnvironmentConfig.debugLog("Exception: reply to message could not be found: " + e.toString()); } Provider.of(context, listen: false).messageDraft.clearQuotedReference(); }); } else { ChatMessage cm = new ChatMessage(o: TextMessageOverlay, d: messageWithoutNewLine); Provider.of(context, listen: false) .cwtch .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, jsonEncode(cm)) .then(_sendMessageHandler); } } } void _sendInvitation(int contact) { Provider.of(context, listen: false) .cwtch .SendInvitation(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, contact) .then(_sendMessageHandler); } void _sendFile(String filePath) { Provider.of(context, listen: false) .cwtch .ShareFile(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, filePath) .then(_sendMessageHandler); } void _sendMessageHandler(dynamic messageJson) { if (Provider.of(context, listen: false).isGroup && Provider.of(context, listen: false).antispamTickets == 0) { final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.acquiringTicketsFromServer)); ScaffoldMessenger.of(context).showSnackBar(snackBar); return; } // At this point we have decided to send the text to the backend, failure is still possible // but it will show as an error-ed message, as such the draft can be purged. Provider.of(context, listen: false).messageDraft.clearDraft(); var profileOnion = Provider.of(context, listen: false).profileOnion; var identifier = Provider.of(context, listen: false).identifier; var profile = Provider.of(context, listen: false); var messageInfo = messageJsonToInfo(profileOnion, identifier, messageJson); if (messageInfo != null) { profile.newMessage( messageInfo.metadata.conversationIdentifier, messageInfo.metadata.messageID, messageInfo.metadata.timestamp, messageInfo.metadata.senderHandle, messageInfo.metadata.senderImage ?? "", messageInfo.metadata.isAuto, messageInfo.wrapper, messageInfo.metadata.contenthash, true, true, ); } Provider.of(context, listen: false).cwtch.SetConversationAttribute(profileOnion, identifier, LastMessageSeenTimeKey, DateTime.now().toIso8601String()); focusNode.requestFocus(); Provider.of(context, listen: false).messageDraft.clearDraft(); } Widget senderInviteChrome(String chrome, String targetName) { var settings = Provider.of(context); return Wrap(children: [ SelectableText( chrome + '\u202F', style: settings.scaleFonts(defaultMessageTextStyle.copyWith(color: Provider.of(context).theme.messageFromMeTextColor)), textAlign: TextAlign.left, maxLines: 2, textWidthBasis: TextWidthBasis.longestLine, ), SelectableText( targetName + '\u202F', style: settings.scaleFonts(defaultMessageTextStyle.copyWith(color: Provider.of(context).theme.messageFromMeTextColor)), textAlign: TextAlign.left, maxLines: 2, textWidthBasis: TextWidthBasis.longestLine, ) ]); } Widget _buildPreviewBox() { var showClickableLinks = Provider.of(context).isExperimentEnabled(ClickableLinksExperiment); var wdgMessage = Padding( padding: EdgeInsets.all(8), child: SelectableLinkify( text: Provider.of(context).messageDraft.messageText + '\n', options: LinkifyOptions(messageFormatting: true, parseLinks: showClickableLinks, looseUrl: true, defaultToHttps: true), linkifiers: [UrlLinkifier()], onOpen: showClickableLinks ? null : null, style: TextStyle( color: Provider.of(context).theme.messageFromMeTextColor, fontFamily: "Inter", fontWeight: FontWeight.normal, fontSize: 16.0 * Provider.of(context).fontScaling, ), linkStyle: TextStyle( color: Provider.of(context).theme.messageFromMeTextColor, fontFamily: "Inter", fontWeight: FontWeight.normal, fontSize: 16.0 * Provider.of(context).fontScaling, ), codeStyle: TextStyle( // note: these colors are flipped fontWeight: FontWeight.normal, fontSize: 16.0 * Provider.of(context).fontScaling, color: Provider.of(context).theme.messageFromOtherTextColor, backgroundColor: Provider.of(context).theme.messageFromOtherBackgroundColor), textAlign: TextAlign.left, textWidthBasis: TextWidthBasis.longestLine, constraints: null, )); var showMessageFormattingPreview = Provider.of(context).isExperimentEnabled(FormattingExperiment); var preview = showMessageFormattingPreview ? IconButton( tooltip: AppLocalizations.of(context)!.tooltipBackToMessageEditing, icon: Icon(Icons.text_fields), onPressed: () { setState(() { showPreview = false; }); }) : Container(); var composeBox = Container( color: Provider.of(context).theme.backgroundMainColor, padding: EdgeInsets.all(2), margin: EdgeInsets.all(2), // 164 minimum height + 16px for every line of text so the entire message is displayed when previewed. height: 164 + ((Provider.of(context).messageDraft.messageText.split("\n").length - 1) * 16), child: Column( children: [ Container( decoration: BoxDecoration(color: Provider.of(context).theme.defaultButtonActiveColor), child: Row(mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.max, children: [preview])), Container( decoration: BoxDecoration(border: Border(top: BorderSide(color: Provider.of(context).theme.defaultButtonActiveColor))), child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [wdgMessage])), ], ), ); return Container( color: Provider.of(context).theme.backgroundMainColor, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [composeBox])); } Widget _buildComposeBox(BuildContext context) { bool cannotSend = Provider.of(context).canSend() == false; bool isGroup = Provider.of(context).isGroup; var showToolbar = Provider.of(context).isExperimentEnabled(FormattingExperiment); var charLength = Provider.of(context).messageDraft.messageText.characters.length; var expectedLength = Provider.of(context).messageDraft.messageText.length; var numberOfBytesMoreThanChar = (expectedLength - charLength); var bold = IconButton( icon: Icon(Icons.format_bold), tooltip: AppLocalizations.of(context)!.tooltipBoldText, onPressed: () { setState(() { var ctrlrCompose = Provider.of(context, listen: false).messageDraft.ctrlCompose; var selected = ctrlrCompose.selection.textInside(ctrlrCompose.text); var selection = ctrlrCompose.selection; var start = ctrlrCompose.selection.start; var end = ctrlrCompose.selection.end; ctrlrCompose.text = ctrlrCompose.text.replaceRange(start, end, "**" + selected + "**"); ctrlrCompose.selection = selection.copyWith(baseOffset: selection.start + 2, extentOffset: selection.start + 2); Provider.of(context, listen: false).messageDraft.ctrlCompose = ctrlrCompose; }); }); var italic = IconButton( icon: Icon(Icons.format_italic), tooltip: AppLocalizations.of(context)!.tooltipItalicize, onPressed: () { setState(() { var ctrlrCompose = Provider.of(context, listen: false).messageDraft.ctrlCompose; var selected = ctrlrCompose.selection.textInside(ctrlrCompose.text); var selection = ctrlrCompose.selection; var start = ctrlrCompose.selection.start; var end = ctrlrCompose.selection.end; ctrlrCompose.text = ctrlrCompose.text.replaceRange(start, end, "*" + selected + "*"); ctrlrCompose.selection = selection.copyWith(baseOffset: selection.start + 1, extentOffset: selection.start + 1); Provider.of(context, listen: false).messageDraft.ctrlCompose = ctrlrCompose; }); }); var code = IconButton( icon: Icon(Icons.code), tooltip: AppLocalizations.of(context)!.tooltipCode, onPressed: () { setState(() { var ctrlrCompose = Provider.of(context, listen: false).messageDraft.ctrlCompose; var selected = ctrlrCompose.selection.textInside(ctrlrCompose.text); var selection = ctrlrCompose.selection; var start = ctrlrCompose.selection.start; var end = ctrlrCompose.selection.end; ctrlrCompose.text = ctrlrCompose.text.replaceRange(start, end, "`" + selected + "`"); ctrlrCompose.selection = selection.copyWith(baseOffset: selection.start + 1, extentOffset: selection.start + 1); Provider.of(context, listen: false).messageDraft.ctrlCompose = ctrlrCompose; }); }); var superscript = IconButton( icon: Icon(Icons.superscript), tooltip: AppLocalizations.of(context)!.tooltipSuperscript, onPressed: () { setState(() { var ctrlrCompose = Provider.of(context, listen: false).messageDraft.ctrlCompose; var selected = ctrlrCompose.selection.textInside(ctrlrCompose.text); var selection = ctrlrCompose.selection; var start = ctrlrCompose.selection.start; var end = ctrlrCompose.selection.end; ctrlrCompose.text = ctrlrCompose.text.replaceRange(start, end, "^" + selected + "^"); ctrlrCompose.selection = selection.copyWith(baseOffset: selection.start + 1, extentOffset: selection.start + 1); Provider.of(context, listen: false).messageDraft.ctrlCompose = ctrlrCompose; }); }); var strikethrough = IconButton( icon: Icon(Icons.format_strikethrough), tooltip: AppLocalizations.of(context)!.tooltipStrikethrough, onPressed: () { setState(() { var ctrlrCompose = Provider.of(context, listen: false).messageDraft.ctrlCompose; var selected = ctrlrCompose.selection.textInside(ctrlrCompose.text); var selection = ctrlrCompose.selection; var start = ctrlrCompose.selection.start; var end = ctrlrCompose.selection.end; ctrlrCompose.text = ctrlrCompose.text.replaceRange(start, end, "~~" + selected + "~~"); ctrlrCompose.selection = selection.copyWith(baseOffset: selection.start + 2, extentOffset: selection.start + 2); Provider.of(context, listen: false).messageDraft.ctrlCompose = ctrlrCompose; }); }); var preview = IconButton( icon: Icon(Icons.text_format), tooltip: AppLocalizations.of(context)!.tooltipPreviewFormatting, onPressed: () { setState(() { showPreview = true; }); }); var vline = Padding( padding: EdgeInsets.symmetric(vertical: 1, horizontal: 2), child: Container(height: 16, width: 1, decoration: BoxDecoration(color: Provider.of(context).theme.messageFromMeTextColor))); var formattingToolbar = Container( decoration: BoxDecoration(color: Provider.of(context).theme.defaultButtonActiveColor), child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [bold, italic, code, superscript, strikethrough, vline, preview])); var textField = Container( decoration: BoxDecoration(border: Border(top: BorderSide(color: Provider.of(context).theme.defaultButtonActiveColor))), child: KeyboardListener( focusNode: FocusNode(), onKeyEvent: handleKeyPress, child: Padding( padding: EdgeInsets.all(8), child: TextFormField( key: Key('txtCompose'), controller: Provider.of(context).messageDraft.ctrlCompose, focusNode: focusNode, autofocus: !Platform.isAndroid, textInputAction: TextInputAction.newline, textCapitalization: TextCapitalization.sentences, keyboardType: TextInputType.multiline, enableIMEPersonalizedLearning: false, minLines: 1, maxLength: max(1, (isGroup ? GroupMessageLengthMax : P2PMessageLengthMax) - numberOfBytesMoreThanChar), autocorrect: true, buildCounter: (context, {currentLength = 0, isFocused = true, maxLength}) { return Text("$currentLength/$maxLength", style: Provider.of(context).scaleFonts(defaultTextStyle)); }, maxLengthEnforcement: MaxLengthEnforcement.enforced, maxLines: 3, onFieldSubmitted: _sendMessage, style: Provider.of(context).scaleFonts(defaultMessageTextStyle).copyWith( fontWeight: FontWeight.w500, ), enabled: true, // always allow editing... onChanged: (String x) { setState(() { // we need to force a rerender here to update the max length count }); }, decoration: InputDecoration( hintText: AppLocalizations.of(context)!.placeholderEnterMessage, hintStyle: Provider.of(context).scaleFonts(defaultMessageTextStyle).copyWith(color: Provider.of(context).theme.sendHintTextColor, fontWeight: FontWeight.bold), enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, enabled: true, suffixIcon: ElevatedButton( key: Key("btnSend"), style: ElevatedButton.styleFrom(padding: EdgeInsets.all(0.0), shape: new RoundedRectangleBorder(borderRadius: new BorderRadius.circular(45.0))), child: Tooltip( message: cannotSend ? (isGroup ? AppLocalizations.of(context)!.serverNotSynced : AppLocalizations.of(context)!.peerOfflineMessage) : (isGroup && Provider.of(context, listen: false).antispamTickets == 0) ? AppLocalizations.of(context)!.acquiringTicketsFromServer : AppLocalizations.of(context)!.sendMessage, child: Icon(CwtchIcons.send_24px, size: 24, color: Provider.of(context).theme.defaultButtonTextColor)), onPressed: cannotSend || (isGroup && Provider.of(context, listen: false).antispamTickets == 0) ? null : _sendMessage, ))), ))); var textEditChildren; if (showToolbar) { textEditChildren = [formattingToolbar, textField]; } else { textEditChildren = [textField]; } var composeBox = Container(color: Provider.of(context).theme.backgroundMainColor, padding: EdgeInsets.all(2), margin: EdgeInsets.all(2), height: 164, child: Column(children: textEditChildren)); var children; Widget invite = Container(); if (Provider.of(context).messageDraft.getInviteHandle() != null) { invite = FutureBuilder( future: Future.value(Provider.of(context).messageDraft.getInviteHandle()), builder: (context, snapshot) { if (snapshot.hasData) { var contactInvite = snapshot.data! as int; var contact = Provider.of(context, listen: false).contactList.getContact(contactInvite); return Container( margin: EdgeInsets.all(5), padding: EdgeInsets.all(5), color: Provider.of(context).theme.messageFromMeBackgroundColor, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Stack(children: [ Container( margin: EdgeInsets.all(5), padding: EdgeInsets.all(5), clipBehavior: Clip.antiAlias, decoration: BoxDecoration(color: Provider.of(context).theme.messageFromMeBackgroundColor), height: 75, child: Row(mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.start, children: [ Padding(padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), child: Icon(CwtchIcons.send_invite, size: 32)), Flexible( child: DefaultTextStyle( textWidthBasis: TextWidthBasis.parent, child: senderInviteChrome("", contact!.nickname), style: Provider.of(context).scaleFonts(defaultTextStyle), overflow: TextOverflow.fade, )) ])), Align( alignment: Alignment.topRight, child: IconButton( icon: Icon(Icons.highlight_remove), splashRadius: Material.defaultSplashRadius / 2, tooltip: AppLocalizations.of(context)!.tooltipRemoveThisQuotedMessage, onPressed: () { Provider.of(context, listen: false).messageDraft.clearInvite(); setState(() {}); }, )), ]), ])); } return Container(); }); } if (Provider.of(context).selectedConversation != null && Provider.of(context).messageDraft.getQuotedMessage() != null) { var quoted = FutureBuilder( future: messageHandler(context, Provider.of(context).selectedProfile!, Provider.of(context).selectedConversation!, ById(Provider.of(context).messageDraft.getQuotedMessage()!.index)), builder: (context, snapshot) { if (snapshot.hasData) { var message = snapshot.data! as Message; var qTextColor = message.getMetadata().senderHandle != Provider.of(context).selectedProfile ? Provider.of(context).theme.messageFromOtherTextColor : Provider.of(context).theme.messageFromMeTextColor; return Container( margin: EdgeInsets.all(5), padding: EdgeInsets.all(5), color: message.getMetadata().senderHandle != Provider.of(context).selectedProfile ? Provider.of(context).theme.messageFromOtherBackgroundColor : Provider.of(context).theme.messageFromMeBackgroundColor, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Stack(children: [ Container( margin: EdgeInsets.all(5), padding: EdgeInsets.all(5), clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: message.getMetadata().senderHandle != Provider.of(context).selectedProfile ? Provider.of(context).theme.messageFromOtherBackgroundColor : Provider.of(context).theme.messageFromMeBackgroundColor, ), height: 75, child: Row(mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.start, children: [ Padding(padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), child: Icon(Icons.reply, size: 32, color: qTextColor)), Flexible( child: DefaultTextStyle( textWidthBasis: TextWidthBasis.parent, child: message.getPreviewWidget(context), style: TextStyle(color: qTextColor), overflow: TextOverflow.fade, )) ])), Align( alignment: Alignment.topRight, child: IconButton( icon: Icon(Icons.highlight_remove), splashRadius: Material.defaultSplashRadius / 2, tooltip: AppLocalizations.of(context)!.tooltipRemoveThisQuotedMessage, onPressed: () { Provider.of(context, listen: false).messageDraft.clearQuotedReference(); setState(() {}); }, )), ]), ])); } else { return MessageLoadingBubble(); } }, ); children = [invite, quoted, composeBox]; } else { children = [invite, composeBox]; } return Container( color: Provider.of(context).theme.backgroundMainColor, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: children)); } // Send the message if enter is pressed without the shift key... void handleKeyPress(KeyEvent event) { var key = event.logicalKey; if ((event.logicalKey == LogicalKeyboardKey.enter && !HardwareKeyboard.instance.isShiftPressed) || event.logicalKey == LogicalKeyboardKey.numpadEnter && HardwareKeyboard.instance.isShiftPressed) { // Don't send when inserting a new line that is not at the end of the message if (Provider.of(context, listen: false).messageDraft.ctrlCompose.selection.baseOffset != Provider.of(context, listen: false).messageDraft.ctrlCompose.text.length) { return; } _sendMessage(); Provider.of(context, listen: false).messageDraft.clearDraft(); } } // explicitly passing BuildContext ctx here is important, change at risk to own health // otherwise some Providers will become inaccessible to subwidgets...? // https://stackoverflow.com/a/63818697 void _modalSendInvitation(BuildContext ctx) { showModalBottomSheet( context: ctx, builder: (BuildContext bcontext) { return Container( height: 200, // bespoke value courtesy of the [TextField] docs child: Center( child: Padding( padding: EdgeInsets.all(10.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(AppLocalizations.of(bcontext)!.invitationLabel), SizedBox( height: 20, ), ChangeNotifierProvider.value( value: Provider.of(ctx, listen: false), child: DropdownContacts(filter: (contact) { return contact.onion != Provider.of(ctx).onion; }, onChanged: (newVal) { setState(() { this.selectedContact = Provider.of(ctx, listen: false).contactList.findContact(newVal)!.identifier; }); })), SizedBox( height: 20, ), ElevatedButton( child: Text(AppLocalizations.of(bcontext)!.inviteBtn, semanticsLabel: AppLocalizations.of(bcontext)!.inviteBtn), onPressed: () { if (this.selectedContact != -1) { Provider.of(context, listen: false).messageDraft.attachInvite(this.selectedContact); } Navigator.pop(bcontext); setState(() {}); }, ), ], )), )); }); } void _confirmFileSend(BuildContext ctx, String path) async { showModalBottomSheet( context: ctx, builder: (BuildContext bcontext) { var showPreview = false; if (Provider.of(context, listen: false).shouldPreview(path)) { showPreview = true; if (imagePreview == null) { imagePreview = new File(path); } } return Container( height: 300, // bespoke value courtesy of the [TextField] docs child: Center( child: Padding( padding: EdgeInsets.all(10.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text(AppLocalizations.of(context)!.msgConfirmSend + " $path?"), SizedBox( height: 20, ), Visibility( visible: showPreview, child: showPreview ? Image.file( imagePreview!, cacheHeight: 150, // limit the amount of space the image can decode too, we keep this high-ish to allow quality previews... filterQuality: FilterQuality.medium, fit: BoxFit.fill, alignment: Alignment.center, height: 150, isAntiAlias: false, errorBuilder: (context, error, stackTrace) { return MalformedBubble(); }, ) : Container()), Visibility( visible: showPreview, child: SizedBox( height: 10, )), Row(mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( child: Text(AppLocalizations.of(context)!.cancel, semanticsLabel: AppLocalizations.of(context)!.cancel), onPressed: () { Navigator.pop(bcontext); }, ), SizedBox( width: 20, ), ElevatedButton( child: Text(AppLocalizations.of(context)!.btnSendFile, semanticsLabel: AppLocalizations.of(context)!.btnSendFile), onPressed: () { _sendFile(path); Navigator.pop(bcontext); }, ), ]), ], )), )); }); } } void _summarizeConversation(BuildContext context, ProfileInfoState profile, Settings settings) async { showModalBottomSheet( builder: ( BuildContext bcontext, ) { return StatefulBuilder(builder: (BuildContext scontext, StateSetter setState /*You can rename this!*/) { if (scontext.mounted) { new Timer.periodic(Duration(seconds: 1), (Timer t) { if (scontext.mounted) { setState(() {}); } }); } var bubble = StaticMessageBubble( profile, settings, MessageMetadata(profile.onion, Provider.of(context).identifier, 1, DateTime.now(), "blodeuwedd", null, null, null, true, false, false, ""), Row(children: [ Provider.of(context).summary == "" ? Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ CircularProgressIndicator(color: settings.theme.defaultButtonActiveColor), Padding(padding: EdgeInsets.all(5.0), child: Text(AppLocalizations.of(context)!.blodeuweddProcessing)) ]) : Flexible(child: Text(Provider.of(context).summary)) ])); var image = Padding( padding: EdgeInsets.all(4.0), child: ProfileImage( imagePath: "assets/blodeuwedd.png", diameter: 48.0, border: settings.theme.portraitOnlineBorderColor, badgeTextColor: Colors.red, badgeColor: Colors.red, )); return Container( height: 300, // bespoke value courtesy of the [TextField] docs child: Container( alignment: Alignment.center, child: Padding( padding: EdgeInsets.all(10.0), child: Padding( padding: EdgeInsets.all(10.0), child: Row( mainAxisSize: MainAxisSize.min, children: [image, Flexible(child: bubble)], ))))); }); }, context: context); }