import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:cwtch/config.dart'; import 'package:cwtch/cwtch_icons_icons.dart'; import 'package:cwtch/models/message.dart'; import 'package:cwtch/models/messages/quotedmessage.dart'; import 'package:cwtch/widgets/messageloadingbubble.dart'; import 'package:cwtch/widgets/profileimage.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.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 'package:path/path.dart' show basename; import '../main.dart'; import '../model.dart'; import '../settings.dart'; import '../widgets/messagelist.dart'; import 'groupsettingsview.dart'; class MessageView extends StatefulWidget { @override _MessageViewState createState() => _MessageViewState(); } class _MessageViewState extends State { final ctrlrCompose = TextEditingController(); final focusNode = FocusNode(); int selectedContact = -1; ItemPositionsListener scrollListener = ItemPositionsListener.create(); ItemScrollController scrollController = ItemScrollController(); @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; } }); 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(); ctrlrCompose.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // After leaving a conversation the selected conversation is set to null... if (Provider.of(context).profileOnion == "") { return Card(child: Center(child: Text(AppLocalizations.of(context)!.addContactFirst))); } var showFileSharing = Provider.of(context).isExperimentEnabled(FileSharingExperiment); var appBarButtons = []; if (Provider.of(context).isOnline()) { if (showFileSharing) { appBarButtons.add(IconButton( icon: Icon(Icons.attach_file, size: 24), tooltip: AppLocalizations.of(context)!.tooltipSendFile, onPressed: _showFilePicker, )); } appBarButtons.add(IconButton( icon: Icon(CwtchIcons.send_invite, size: 24), tooltip: AppLocalizations.of(context)!.sendInvite, onPressed: () { _modalSendInvitation(context); })); } appBarButtons.add(IconButton( 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 WillPopScope( onWillPop: _onWillPop, child: Scaffold( floatingActionButton: appState.unreadMessagesBelow ? FloatingActionButton( child: Icon(Icons.arrow_downward), onPressed: () { Provider.of(context, listen: false).initialScrollIndex = 0; Provider.of(context, listen: false).unreadMessagesBelow = false; scrollController.scrollTo(index: 0, duration: Duration(milliseconds: 600)); }) : null, appBar: AppBar( // setting leading to null makes it do the default behaviour; container() hides it leading: Provider.of(context).uiColumns(appState.isLandscape(context)).length > 1 ? Container() : null, title: Row(children: [ ProfileImage( imagePath: Provider.of(context).imagePath, diameter: 42, border: Provider.of(context).current().portraitOnlineBorderColor(), badgeTextColor: Colors.red, badgeColor: Colors.red, ), SizedBox( width: 10, ), Expanded( child: Text( Provider.of(context).nickname, overflow: TextOverflow.ellipsis, )) ]), actions: appBarButtons, ), body: Padding(padding: EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 108.0), child: MessageList(scrollController, scrollListener)), bottomSheet: _buildComposeBox(), )); } Future _onWillPop() async { Provider.of(context, listen: false).unreadMessages = 0; Provider.of(context, listen: false).selectedConversation = null; return true; } void _pushContactSettings() { Navigator.of(context).push(MaterialPageRoute( builder: (BuildContext bcontext) { if (Provider.of(context, listen: false).isGroup == true) { return MultiProvider( providers: [ChangeNotifierProvider.value(value: Provider.of(context))], child: GroupSettingsView(), ); } else { return MultiProvider( providers: [ChangeNotifierProvider.value(value: Provider.of(context))], child: PeerSettingsView(), ); } }, )); } void _sendMessage([String? ignoredParam]) { if (ctrlrCompose.value.text.isNotEmpty) { if (Provider.of(context, listen: false).selectedConversation != null && Provider.of(context, listen: false).selectedIndex != null) { Provider.of(context, listen: false) .cwtch .GetMessageByID(Provider.of(context, listen: false).selectedProfile!, Provider.of(context, listen: false).selectedConversation!, Provider.of(context, listen: false).selectedIndex!) .then((data) { try { var messageWrapper = jsonDecode(data! as String); var bytes1 = utf8.encode(messageWrapper["PeerID"] + messageWrapper['Message']); var digest1 = sha256.convert(bytes1); var contentHash = base64Encode(digest1.bytes); var quotedMessage = jsonEncode(QuotedMessageStructure(contentHash, ctrlrCompose.value.text)); 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)); } catch (e) {} Provider.of(context, listen: false).selectedIndex = null; _sendMessageHelper(); }); } else { ChatMessage cm = new ChatMessage(o: TextMessageOverlay, d: ctrlrCompose.value.text); Provider.of(context, listen: false) .cwtch .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, jsonEncode(cm)); _sendMessageHelper(); } } } void _sendInvitation([String? ignoredParam]) { Provider.of(context, listen: false) .cwtch .SendInvitation(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, this.selectedContact); _sendMessageHelper(); } void _sendFile(String filePath) { Provider.of(context, listen: false) .cwtch .ShareFile(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, filePath); _sendMessageHelper(); } void _sendMessageHelper() { ctrlrCompose.clear(); focusNode.requestFocus(); Future.delayed(const Duration(milliseconds: 80), () { Provider.of(context, listen: false).contactList.getContact(Provider.of(context, listen: false).identifier)?.bumpMessageCache(); Provider.of(context, listen: false).newMarker++; // Resort the contact list... Provider.of(context, listen: false).contactList.updateLastMessageTime(Provider.of(context, listen: false).identifier, DateTime.now()); }); } Widget _buildComposeBox() { bool isOffline = Provider.of(context).isOnline() == false; var composeBox = Container( color: Provider.of(context).theme.backgroundMainColor(), padding: EdgeInsets.all(2), margin: EdgeInsets.all(2), height: 100, child: Row( children: [ Expanded( child: Container( decoration: BoxDecoration(border: Border(top: BorderSide(color: Provider.of(context).theme.defaultButtonActiveColor()))), child: RawKeyboardListener( focusNode: FocusNode(), onKey: handleKeyPress, child: Padding( padding: EdgeInsets.all(8), child: TextFormField( key: Key('txtCompose'), controller: ctrlrCompose, focusNode: focusNode, autofocus: !Platform.isAndroid, textInputAction: TextInputAction.newline, keyboardType: TextInputType.multiline, enableIMEPersonalizedLearning: false, minLines: 1, maxLines: null, onFieldSubmitted: _sendMessage, enabled: !isOffline, decoration: InputDecoration( hintText: isOffline ? "" : AppLocalizations.of(context)!.placeholderEnterMessage, hintStyle: TextStyle(color: Provider.of(context).theme.altTextColor()), enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, enabled: true, suffixIcon: ElevatedButton( child: Icon(CwtchIcons.send_24px, size: 24, color: Provider.of(context).theme.defaultButtonTextColor()), onPressed: isOffline ? null : _sendMessage, ))), )))), ], ), ); var children; if (Provider.of(context).selectedConversation != null && Provider.of(context).selectedIndex != null) { var quoted = FutureBuilder( future: messageHandler(context, Provider.of(context).selectedProfile!, Provider.of(context).selectedConversation!, Provider.of(context).selectedIndex!, byID: true), builder: (context, snapshot) { if (snapshot.hasData) { var message = snapshot.data! as Message; 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: [ Align( alignment: Alignment.topRight, child: IconButton( icon: Icon(Icons.highlight_remove), tooltip: AppLocalizations.of(context)!.tooltipRemoveThisQuotedMessage, onPressed: () { Provider.of(context, listen: false).selectedIndex = null; }, )), Align( alignment: Alignment.topLeft, child: Padding(padding: EdgeInsets.all(2.0), child: Icon(Icons.reply)), ) ]), Wrap( runAlignment: WrapAlignment.spaceEvenly, alignment: WrapAlignment.center, runSpacing: 1.0, children: [Center(widthFactor: 1.0, child: Padding(padding: EdgeInsets.all(10.0), child: message.getPreviewWidget(context)))]), ])); } else { return MessageLoadingBubble(); } }, ); children = [quoted, composeBox]; } else { children = [composeBox]; } return Column(mainAxisSize: MainAxisSize.min, children: children); } // Send the message if enter is pressed without the shift key... void handleKeyPress(event) { var data = event.data as RawKeyEventData; if (data.logicalKey == LogicalKeyboardKey.enter && !event.isShiftPressed) { final messageWithoutNewLine = ctrlrCompose.value.text.trimRight(); ctrlrCompose.value = TextEditingValue(text: messageWithoutNewLine); _sendMessage(); } } void placeHolder() => {}; // 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, 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(context).onion; }, onChanged: (newVal) { setState(() { this.selectedContact = Provider.of(context).contactList.findContact(newVal)!.identifier; }); })), SizedBox( height: 20, ), ElevatedButton( child: Text(AppLocalizations.of(bcontext)!.inviteBtn, semanticsLabel: AppLocalizations.of(bcontext)!.inviteBtn), onPressed: () { if (this.selectedContact != -1) { this._sendInvitation(); } Navigator.pop(bcontext); }, ), ], )), )); }); } void _showFilePicker() async { FilePickerResult? result = await FilePicker.platform.pickFiles(); if (result != null) { File file = File(result.files.first.path); // We have a maximum number of bytes we can represent in terms of // a manifest (see : https://git.openprivacy.ca/cwtch.im/cwtch/src/branch/master/protocol/files/manifest.go#L25) if (file.lengthSync() <= 10737418240) { print("Sending " + file.path); _sendFile(file.path); } else { print("file size cannot exceed 10 gigabytes"); //todo: toast error } } } }