diff --git a/LIBCWTCH-GO-MACOS.version b/LIBCWTCH-GO-MACOS.version index 1f70ab7a..131da5bc 100644 --- a/LIBCWTCH-GO-MACOS.version +++ b/LIBCWTCH-GO-MACOS.version @@ -1 +1 @@ -2022-02-04-16-57-v1.5.4-28-g4e4e331 \ No newline at end of file +2022-02-07-17-39-v1.5.4-31-g17acc3b \ No newline at end of file diff --git a/LIBCWTCH-GO.version b/LIBCWTCH-GO.version index 61cc1a7b..ae1b1c97 100644 --- a/LIBCWTCH-GO.version +++ b/LIBCWTCH-GO.version @@ -1 +1 @@ -2022-02-04-21-58-v1.5.4-28-g4e4e331 \ No newline at end of file +2022-02-07-22-31-v1.5.4-31-g17acc3b \ No newline at end of file diff --git a/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt b/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt index cf207e99..44ba19f3 100644 --- a/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt +++ b/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt @@ -77,7 +77,7 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) : } val loader = FlutterInjector.instance().flutterLoader() - val key = loader.getLookupKeyForAsset("assets/" + data.getString("Picture"))//"assets/profiles/001-centaur.png") + val key = loader.getLookupKeyForAsset("assets/" + data.getString("picture"))//"assets/profiles/001-centaur.png") val fh = applicationContext.assets.open(key) diff --git a/lib/constants.dart b/lib/constants.dart new file mode 100644 index 00000000..1edea436 --- /dev/null +++ b/lib/constants.dart @@ -0,0 +1,3 @@ +const int MaxImageFileSharingSize = 20971520; + +const int MaxGeneralFileSharingSize = 10737418240; diff --git a/lib/controllers/filesharing.dart b/lib/controllers/filesharing.dart new file mode 100644 index 00000000..cbfdaa20 --- /dev/null +++ b/lib/controllers/filesharing.dart @@ -0,0 +1,30 @@ +import 'dart:io'; +import 'package:cwtch/models/appstate.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +void showFilePicker(BuildContext ctx, int maxBytes, Function(File) onSuccess, Function onError, Function onCancel) async { + // only allow one file picker at a time + // note: ideally we would destroy file picker when leaving a conversation + // but we don't currently have that option. + // we need to store AppState in a variable because ctx might be destroyed + // while awaiting for pickFiles. + var appstate = Provider.of(ctx, listen: false); + appstate.disableFilePicker = true; + // currently lockParentWindow only works on Windows... + FilePickerResult? result = await FilePicker.platform.pickFiles(lockParentWindow: true); + appstate.disableFilePicker = false; + if (result != null && result.files.first.path != 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() <= maxBytes) { + onSuccess(file); + } else { + onError(); + } + } else { + onCancel(); + } +} diff --git a/lib/cwtch/cwtchNotifier.dart b/lib/cwtch/cwtchNotifier.dart index fee09984..055b781d 100644 --- a/lib/cwtch/cwtchNotifier.dart +++ b/lib/cwtch/cwtchNotifier.dart @@ -55,7 +55,7 @@ class CwtchNotifier { } EnvironmentConfig.debugLog("NewPeer $data"); // if tag != v1-defaultPassword then it is either encrypted OR it is an unencrypted account created during pre-beta... - profileCN.add(data["Identity"], data["name"], data["picture"], data["ContactsJson"], data["ServerList"], data["Online"] == "true", data["tag"] != "v1-defaultPassword"); + profileCN.add(data["Identity"], data["name"], data["picture"], data["defaultPicture"], data["ContactsJson"], data["ServerList"], data["Online"] == "true", data["tag"] != "v1-defaultPassword"); break; case "ContactCreated": EnvironmentConfig.debugLog("ContactCreated $data"); @@ -67,6 +67,7 @@ class CwtchNotifier { nickname: data["nick"], status: data["status"], imagePath: data["picture"], + defaultImagePath: data["defaultPicture"], blocked: data["blocked"] == "true", accepted: data["accepted"] == "true", savePeerHistory: data["saveConversationHistory"] == null ? "DeleteHistoryConfirmed" : data["saveConversationHistory"], @@ -106,7 +107,8 @@ class CwtchNotifier { profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], int.parse(data["ConversationID"]), data["GroupID"], blocked: false, // we created accepted: true, // we created - imagePath: data["PicturePath"], + imagePath: data["picture"], + defaultImagePath: data["picture"], nickname: data["GroupName"], status: status, server: data["GroupServer"], @@ -147,7 +149,7 @@ class CwtchNotifier { var messageID = int.parse(data["Index"]); var timestamp = DateTime.tryParse(data['TimestampReceived'])!; var senderHandle = data['RemotePeer']; - var senderImage = data['Picture']; + var senderImage = data['picture']; var isAuto = data['Auto'] == "true"; String? contenthash = data['ContentHash']; var selectedProfile = appState.selectedProfile == data["ProfileOnion"]; @@ -199,7 +201,7 @@ class CwtchNotifier { if (data["ProfileOnion"] != data["RemotePeer"]) { var idx = int.parse(data["Index"]); var senderHandle = data['RemotePeer']; - var senderImage = data['Picture']; + var senderImage = data['picture']; var timestampSent = DateTime.tryParse(data['TimestampSent'])!; var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier); var currentTotal = contact!.totalMessages; @@ -301,7 +303,7 @@ class CwtchNotifier { profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], identifier, groupInvite["GroupID"], blocked: false, // NewGroup only issued on accepting invite accepted: true, // NewGroup only issued on accepting invite - imagePath: data["PicturePath"], + imagePath: data["picture"], nickname: groupInvite["GroupName"], server: groupInvite["ServerHost"], status: status, @@ -322,15 +324,28 @@ class CwtchNotifier { profileCN.getProfile(data["ProfileOnion"])?.contactList.resort(); break; case "NewRetValMessageFromPeer": - if (data["Path"] == "profile.name") { + if (data["Path"] == "profile.name" && data["Exists"] == "true") { if (data["Data"].toString().trim().length > 0) { // Update locally on the UI... if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) { profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.nickname = data["Data"]; } } - } else if (data['Path'] == "profile.picture") { - // Not yet.. + } else if (data['Path'] == "profile.custom-profile-image" && data["Exists"] == "true") { + EnvironmentConfig.debugLog("received ret val of custom profile image: $data"); + String fileKey = data['Data']; + String filePath = data['FilePath']; + bool downloaded = data['FileDownloadFinished'] == "true"; + if (downloaded) { + if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) { + profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.imagePath = filePath; + } + } else { + var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]); + if (contact != null) { + profileCN.getProfile(data["ProfileOnion"])?.waitForDownloadComplete(contact.identifier, fileKey); + } + } } else { EnvironmentConfig.debugLog("unhandled ret val event: ${data['Path']}"); } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 5ac7c7d8..cbea3bfd 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1,6 +1,7 @@ { "@@locale": "de", - "@@last_modified": "2022-01-28T19:57:41+01:00", + "@@last_modified": "2022-02-07T21:17:01+01:00", + "tooltipSelectACustomProfileImage": "Select a Custom Profile Image", "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", "torSettingsEnableCache": "Cache Tor Consensus", "labelTorNetwork": "Tor Network", @@ -162,7 +163,7 @@ "newPassword": "Neues Passwort", "yesLeave": "Ja, diese Unterhaltung beenden", "reallyLeaveThisGroupPrompt": "Bist du sicher, dass du diese Unterhaltung beenden möchtest? Alle Nachrichten und Attribute werden gelöscht.", - "leaveGroup": "Unterhaltung beenden", + "leaveConversation": "Unterhaltung beenden", "inviteToGroup": "Du wurdest eingeladen einer Gruppe beizutreten:", "titleManageServers": "Server verwalten", "dateNever": "Nie", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index df6497f1..6f481895 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1,6 +1,7 @@ { "@@locale": "en", - "@@last_modified": "2022-01-28T19:57:41+01:00", + "@@last_modified": "2022-02-07T21:17:01+01:00", + "tooltipSelectACustomProfileImage": "Select a Custom Profile Image", "editProfile": "Edit Profile", "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", "torSettingsEnableCache": "Cache Tor Consensus", @@ -164,7 +165,7 @@ "newPassword": "New Password", "yesLeave": "Yes, Leave This Conversation", "reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.", - "leaveGroup": "Leave This Conversation", + "leaveConversation": "Leave This Conversation", "inviteToGroup": "You have been invited to join a group:", "pasteAddressToAddContact": "Paste a cwtch address, invitation or key bundle here to add a new conversation", "tooltipAddContact": "Add a new contact or conversation", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 5f418b75..ff93defc 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1,6 +1,7 @@ { "@@locale": "es", - "@@last_modified": "2022-01-28T19:57:41+01:00", + "@@last_modified": "2022-02-07T21:17:01+01:00", + "tooltipSelectACustomProfileImage": "Select a Custom Profile Image", "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", "torSettingsEnableCache": "Cache Tor Consensus", "labelTorNetwork": "Tor Network", @@ -148,7 +149,7 @@ "newPassword": "New Password", "yesLeave": "Yes, Leave This Conversation", "reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.", - "leaveGroup": "Leave This Conversation", + "leaveConversation": "Leave This Conversation", "inviteToGroup": "You have been invited to join a group:", "titleManageServers": "Manage Servers", "dateNever": "Never", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index dd2c0544..7e8e365b 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1,6 +1,7 @@ { "@@locale": "fr", - "@@last_modified": "2022-01-28T19:57:41+01:00", + "@@last_modified": "2022-02-07T21:17:01+01:00", + "tooltipSelectACustomProfileImage": "Select a Custom Profile Image", "editProfile": "Modifier le profil", "settingTheme": "Utilisez des thèmes clairs", "torSettingsUseCustomTorServiceConfiguration": "Utiliser une configuration personnalisée du service Tor (torrc)", @@ -213,7 +214,7 @@ "dateNever": "Jamais", "titleManageServers": "Gérer les serveurs", "inviteToGroup": "Vous avez été invité à rejoindre un groupe :", - "leaveGroup": "Quittez cette conversation", + "leaveConversation": "Quittez cette conversation", "reallyLeaveThisGroupPrompt": "Êtes-vous sûr de vouloir quitter cette conversation ? Tous les messages et attributs seront supprimés.", "yesLeave": "Oui, quittez cette conversation", "noPasswordWarning": "Ne pas utiliser de mot de passe sur ce compte signifie que toutes les données stockées localement ne seront pas chiffrées.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index b94f3e24..ac17d6b7 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1,6 +1,7 @@ { "@@locale": "it", - "@@last_modified": "2022-01-28T19:57:41+01:00", + "@@last_modified": "2022-02-07T21:17:01+01:00", + "tooltipSelectACustomProfileImage": "Select a Custom Profile Image", "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", "torSettingsEnableCache": "Cache Tor Consensus", "labelTorNetwork": "Tor Network", @@ -38,7 +39,7 @@ "copiedToClipboardNotification": "Copiato negli Appunti", "groupNameLabel": "Nome del gruppo", "titleManageServers": "Gestisci i Server", - "leaveGroup": "Lascia Questa Conversazione", + "leaveConversation": "Lascia Questa Conversazione", "yesLeave": "Sì, Lascia Questa Conversazione", "newPassword": "Nuova Password", "sendMessage": "Invia Messaggio", diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 3866a59b..cf32bf98 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1,6 +1,7 @@ { "@@locale": "pl", - "@@last_modified": "2022-01-28T19:57:41+01:00", + "@@last_modified": "2022-02-07T21:17:01+01:00", + "tooltipSelectACustomProfileImage": "Select a Custom Profile Image", "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", "torSettingsEnableCache": "Cache Tor Consensus", "labelTorNetwork": "Tor Network", @@ -209,7 +210,7 @@ "chatHistoryDefault": "Ta konwersacja zostanie usunięta gdy zamkniesz Cwtch! Możesz włączyć zapisywanie wiadomości dla każdej konwersacji osobno w menu w prawym górnym rogu.", "yesLeave": "Opuść", "reallyLeaveThisGroupPrompt": "Na pewno chcesz opuścić tę grupę? Wszystkie wiadomości i atrybuty zostaną usunięte.", - "leaveGroup": "Opuść grupę", + "leaveConversation": "Opuść grupę", "inviteToGroup": "Zaproszono Cię do grupy:", "dateNever": "Nigdy", "dateLastYear": "Rok temu", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 41142267..a8350528 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1,6 +1,7 @@ { "@@locale": "pt", - "@@last_modified": "2022-01-28T19:57:41+01:00", + "@@last_modified": "2022-02-07T21:17:01+01:00", + "tooltipSelectACustomProfileImage": "Select a Custom Profile Image", "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", "torSettingsEnableCache": "Cache Tor Consensus", "labelTorNetwork": "Tor Network", @@ -148,7 +149,7 @@ "newPassword": "New Password", "yesLeave": "Yes, Leave This Conversation", "reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.", - "leaveGroup": "Leave This Conversation", + "leaveConversation": "Leave This Conversation", "inviteToGroup": "You have been invited to join a group:", "titleManageServers": "Manage Servers", "dateNever": "Never", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 5357f2cb..be2172b4 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1,6 +1,7 @@ { "@@locale": "ru", - "@@last_modified": "2022-01-28T19:57:41+01:00", + "@@last_modified": "2022-02-07T21:17:01+01:00", + "tooltipSelectACustomProfileImage": "Select a Custom Profile Image", "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", "torSettingsEnableCache": "Cache Tor Consensus", "labelTorNetwork": "Tor Network", @@ -164,7 +165,7 @@ "newPassword": "Новый пароль", "yesLeave": "Да, оставить этот чат", "reallyLeaveThisGroupPrompt": "Вы уверены, что хотите закончить этот разговор? Все сообщения будут удалены.", - "leaveGroup": "Да, оставить этот чат", + "leaveConversation": "Да, оставить этот чат", "inviteToGroup": "Вас пригласили присоединиться к группе:", "titleManageServers": "Управление серверами", "dateNever": "Никогда", diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 7f840747..38cd31cd 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -14,6 +14,7 @@ class ContactInfoState extends ChangeNotifier { late bool _blocked; late String _status; late String _imagePath; + late String _defaultImagePath; late String _savePeerHistory; late int _unreadMessages = 0; late int _totalMessages = 0; @@ -37,6 +38,7 @@ class ContactInfoState extends ChangeNotifier { blocked = false, status = "", imagePath = "", + defaultImagePath = "", savePeerHistory = "DeleteHistoryConfirmed", numMessages = 0, numUnread = 0, @@ -49,6 +51,7 @@ class ContactInfoState extends ChangeNotifier { this._blocked = blocked; this._status = status; this._imagePath = imagePath; + this._defaultImagePath = defaultImagePath; this._totalMessages = numMessages; this._unreadMessages = numUnread; this._savePeerHistory = savePeerHistory; @@ -166,6 +169,13 @@ class ContactInfoState extends ChangeNotifier { notifyListeners(); } + String get defaultImagePath => this._defaultImagePath; + + set defaultImagePath(String newVal) { + this._defaultImagePath = newVal; + notifyListeners(); + } + DateTime get lastMessageTime => this._lastMessageTime; set lastMessageTime(DateTime newVal) { diff --git a/lib/models/profile.dart b/lib/models/profile.dart index ee10ec47..070a25cc 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -14,9 +14,11 @@ class ProfileInfoState extends ChangeNotifier { final String onion; String _nickname = ""; String _imagePath = ""; + String _defaultImagePath = ""; int _unreadMessages = 0; bool _online = false; Map _downloads = Map(); + Map _downloadTriggers = Map(); // assume profiles are encrypted...this will be set to false // in the constructor if the profile is encrypted with the defacto password. @@ -26,14 +28,17 @@ class ProfileInfoState extends ChangeNotifier { required this.onion, nickname = "", imagePath = "", + defaultImagePath = "", unreadMessages = 0, contactsJson = "", serversJson = "", online = false, encrypted = true, + String, }) { this._nickname = nickname; this._imagePath = imagePath; + this._defaultImagePath = defaultImagePath; this._unreadMessages = unreadMessages; this._online = online; this._encrypted = encrypted; @@ -49,6 +54,7 @@ class ProfileInfoState extends ChangeNotifier { nickname: contact["name"], status: contact["status"], imagePath: contact["picture"], + defaultImagePath: contact["isGroup"] ? contact["picture"] : contact["defaultPicture"], accepted: contact["accepted"], blocked: contact["blocked"], savePeerHistory: contact["saveConversationHistory"], @@ -114,6 +120,12 @@ class ProfileInfoState extends ChangeNotifier { notifyListeners(); } + String get defaultImagePath => this._defaultImagePath; + set defaultImagePath(String newVal) { + this._defaultImagePath = newVal; + notifyListeners(); + } + int get unreadMessages => this._unreadMessages; set unreadMessages(int newVal) { this._unreadMessages = newVal; @@ -160,6 +172,7 @@ class ProfileInfoState extends ChangeNotifier { contact["identifier"], contact["onion"], nickname: contact["name"], + defaultImagePath: contact["defaultPicture"], status: contact["status"], imagePath: contact["picture"], accepted: contact["accepted"], @@ -178,22 +191,14 @@ class ProfileInfoState extends ChangeNotifier { this._contacts.resort(); } - void newMessage(int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) { + void newMessage( + int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) { if (!selectedProfile) { unreadMessages++; notifyListeners(); } - contactList.newMessage( - identifier, - messageID, - timestamp, - senderHandle, - senderImage, - isAuto, - data, - contenthash, - selectedConversation); + contactList.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, selectedConversation); } void downloadInit(String fileKey, int numChunks) { @@ -232,6 +237,15 @@ class ProfileInfoState extends ChangeNotifier { // so setting numChunks correctly shouldn't matter this.downloadInit(fileKey, 1); } + + // Update the contact with a custom profile image if we are + // waiting for one... + if (this._downloadTriggers.containsKey(fileKey)) { + int identifier = this._downloadTriggers[fileKey]!; + this.contactList.getContact(identifier)!.imagePath = finalPath; + notifyListeners(); + } + // only update if different if (!this._downloads[fileKey]!.complete) { this._downloads[fileKey]!.timeEnd = DateTime.now(); @@ -308,4 +322,9 @@ class ProfileInfoState extends ChangeNotifier { } return prettyBytes((bytes / seconds).round()) + "/s"; } + + void waitForDownloadComplete(int identifier, String fileKey) { + _downloadTriggers[fileKey] = identifier; + notifyListeners(); + } } diff --git a/lib/models/profilelist.dart b/lib/models/profilelist.dart index 8cae18b5..53a12384 100644 --- a/lib/models/profilelist.dart +++ b/lib/models/profilelist.dart @@ -6,10 +6,11 @@ class ProfileListState extends ChangeNotifier { List _profiles = []; int get num => _profiles.length; - void add(String onion, String name, String picture, String contactsJson, String serverJson, bool online, bool encrypted) { + void add(String onion, String name, String picture, String defaultPicture, String contactsJson, String serverJson, bool online, bool encrypted) { var idx = _profiles.indexWhere((element) => element.onion == onion); if (idx == -1) { - _profiles.add(ProfileInfoState(onion: onion, nickname: name, imagePath: picture, contactsJson: contactsJson, serversJson: serverJson, online: online, encrypted: encrypted)); + _profiles.add(ProfileInfoState( + onion: onion, nickname: name, imagePath: picture, defaultImagePath: defaultPicture, contactsJson: contactsJson, serversJson: serverJson, online: online, encrypted: encrypted)); } else { _profiles[idx].updateFrom(onion, name, picture, contactsJson, serverJson, online); } @@ -28,7 +29,5 @@ class ProfileListState extends ChangeNotifier { notifyListeners(); } - int generateUnreadCount(String selectedProfile) => _profiles.where( (p) => p.onion != selectedProfile ).fold(0, (i, p) => i + p.unreadMessages); - - + int generateUnreadCount(String selectedProfile) => _profiles.where((p) => p.onion != selectedProfile).fold(0, (i, p) => i + p.unreadMessages); } diff --git a/lib/views/addeditprofileview.dart b/lib/views/addeditprofileview.dart index 9841712b..0670058a 100644 --- a/lib/views/addeditprofileview.dart +++ b/lib/views/addeditprofileview.dart @@ -1,10 +1,10 @@ -import 'dart:convert'; import 'dart:io'; -import 'dart:math'; import 'package:cwtch/config.dart'; import 'package:cwtch/cwtch/cwtch.dart'; +import 'package:cwtch/models/appstate.dart'; import 'package:cwtch/models/profile.dart'; +import 'package:cwtch/controllers/filesharing.dart' as filesharing; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:cwtch/widgets/buttontextfield.dart'; @@ -15,6 +15,7 @@ import 'package:cwtch/widgets/textfield.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import '../constants.dart'; import '../cwtch_icons_icons.dart'; import '../errorHandler.dart'; import '../main.dart'; @@ -89,14 +90,39 @@ class _AddEditProfileViewState extends State { Visibility( visible: Provider.of(context).onion.isNotEmpty, child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - ProfileImage( - imagePath: Provider.of(context).imagePath, - diameter: 120, - maskOut: false, - border: theme.theme.portraitOnlineBorderColor, - badgeTextColor: Colors.red, - badgeColor: Colors.red, - ) + MouseRegion( + cursor: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) ? SystemMouseCursors.click : SystemMouseCursors.basic, + child: GestureDetector( + // don't allow setting of profile images if the image previews experiment is disabled. + onTap: Provider.of(context, listen: false).disableFilePicker || !Provider.of(context, listen: false).isExperimentEnabled(ImagePreviewsExperiment) + ? null + : () { + filesharing.showFilePicker(context, MaxImageFileSharingSize, (File file) { + var profile = Provider.of(context, listen: false).onion; + // Share this image publicly (conversation handle == -1) + Provider.of(context, listen: false).cwtch.ShareFile(profile, -1, file.path); + // update the image cache locally + Provider.of(context, listen: false).imagePath = file.path; + }, () { + final snackBar = SnackBar( + content: Text(AppLocalizations.of(context)!.msgFileTooBig), + duration: Duration(seconds: 4), + ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + }, () {}); + }, + child: ProfileImage( + imagePath: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) + ? Provider.of(context).imagePath + : Provider.of(context).defaultImagePath, + diameter: 120, + tooltip: + Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) ? AppLocalizations.of(context)!.tooltipSelectACustomProfileImage : "", + maskOut: false, + border: theme.theme.portraitOnlineBorderColor, + badgeTextColor: theme.theme.portraitContactBadgeTextColor, + badgeColor: theme.theme.portraitContactBadgeColor, + badgeEdit: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment)))) ])), Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ CwtchLabel(label: AppLocalizations.of(context)!.displayNameLabel), diff --git a/lib/views/contactsview.dart b/lib/views/contactsview.dart index d48ffada..ad3a5c8f 100644 --- a/lib/views/contactsview.dart +++ b/lib/views/contactsview.dart @@ -86,11 +86,10 @@ class _ContactsViewState extends State { Navigator.of(context).pop(); }, ), - StreamBuilder( stream: Provider.of(context).getUnreadProfileNotifyStream(), builder: (BuildContext context, AsyncSnapshot unreadCountSnapshot) { - int unreadCount = Provider.of(context).generateUnreadCount(Provider.of(context).selectedProfile ?? "") ; + int unreadCount = Provider.of(context).generateUnreadCount(Provider.of(context).selectedProfile ?? ""); return Visibility( visible: unreadCount > 0, @@ -104,7 +103,9 @@ class _ContactsViewState extends State { title: RepaintBoundary( child: Row(children: [ ProfileImage( - imagePath: Provider.of(context).imagePath, + imagePath: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) + ? Provider.of(context).imagePath + : Provider.of(context).defaultImagePath, diameter: 42, border: Provider.of(context).current().portraitOnlineBorderColor, badgeTextColor: Colors.red, diff --git a/lib/views/globalsettingsview.dart b/lib/views/globalsettingsview.dart index 71e8a5a3..71eea58c 100644 --- a/lib/views/globalsettingsview.dart +++ b/lib/views/globalsettingsview.dart @@ -251,6 +251,7 @@ class _GlobalSettingsViewState extends State { settings.enableExperiment(FileSharingExperiment); } else { settings.disableExperiment(FileSharingExperiment); + settings.disableExperiment(ImagePreviewsExperiment); } saveSettings(context); }, diff --git a/lib/views/groupsettingsview.dart b/lib/views/groupsettingsview.dart index 18855e7e..1026bf05 100644 --- a/lib/views/groupsettingsview.dart +++ b/lib/views/groupsettingsview.dart @@ -159,7 +159,7 @@ class _GroupSettingsViewState extends State { ), Row(crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.end, children: [ Tooltip( - message: AppLocalizations.of(context)!.leaveGroup, + message: AppLocalizations.of(context)!.leaveConversation, child: TextButton.icon( onPressed: () { showAlertDialog(context); @@ -167,7 +167,7 @@ class _GroupSettingsViewState extends State { style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.transparent)), icon: Icon(CwtchIcons.leave_group), label: Text( - AppLocalizations.of(context)!.leaveGroup, + AppLocalizations.of(context)!.leaveConversation, style: TextStyle(decoration: TextDecoration.underline), ), )) diff --git a/lib/views/messageview.dart b/lib/views/messageview.dart index 8db678ff..3630034d 100644 --- a/lib/views/messageview.dart +++ b/lib/views/messageview.dart @@ -11,7 +11,7 @@ import 'package:cwtch/models/profile.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:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; @@ -23,6 +23,7 @@ import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../constants.dart'; import '../main.dart'; import '../settings.dart'; import '../widgets/messagelist.dart'; @@ -92,7 +93,16 @@ class _MessageViewState extends State { onPressed: Provider.of(context).disableFilePicker ? null : () { - _showFilePicker(context); + 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); + }, () {}); }, )); } @@ -129,7 +139,9 @@ class _MessageViewState extends State { leading: Provider.of(context).uiColumns(appState.isLandscape(context)).length > 1 ? Container() : null, title: Row(children: [ ProfileImage( - imagePath: Provider.of(context).imagePath, + imagePath: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) + ? Provider.of(context).imagePath + : Provider.of(context).defaultImagePath, diameter: 42, border: Provider.of(context).current().portraitOnlineBorderColor, badgeTextColor: Colors.red, @@ -415,36 +427,6 @@ class _MessageViewState extends State { }); } - void _showFilePicker(BuildContext ctx) async { - imagePreview = null; - - // only allow one file picker at a time - // note: ideally we would destroy file picker when leaving a conversation - // but we don't currently have that option. - // we need to store AppState in a variable because ctx might be destroyed - // while awaiting for pickFiles. - var appstate = Provider.of(ctx, listen: false); - appstate.disableFilePicker = true; - // currently lockParentWindow only works on Windows... - FilePickerResult? result = await FilePicker.platform.pickFiles(lockParentWindow: true); - appstate.disableFilePicker = false; - if (result != null && result.files.first.path != 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); - _confirmFileSend(ctx, file.path); - } else { - final snackBar = SnackBar( - content: Text(AppLocalizations.of(context)!.msgFileTooBig), - duration: Duration(seconds: 4), - ); - ScaffoldMessenger.of(ctx).showSnackBar(snackBar); - } - } - } - void _confirmFileSend(BuildContext ctx, String path) async { showModalBottomSheet( context: ctx, diff --git a/lib/views/peersettingsview.dart b/lib/views/peersettingsview.dart index 0ab762ee..0206c175 100644 --- a/lib/views/peersettingsview.dart +++ b/lib/views/peersettingsview.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:cwtch/cwtch_icons_icons.dart'; import 'package:cwtch/models/appstate.dart'; import 'package:cwtch/models/contact.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:flutter/services.dart'; import 'package:cwtch/widgets/buttontextfield.dart'; import 'package:cwtch/widgets/cwtchlabel.dart'; @@ -216,11 +217,29 @@ class _PeerSettingsViewState extends State { Navigator.of(context).popUntil((route) => route.settings.name == "conversations"); // dismiss dialog }); }, - icon: Icon(CwtchIcons.leave_chat), + icon: Icon(Icons.archive), label: Text(AppLocalizations.of(context)!.archiveConversation), )) + ]), + SizedBox( + height: 20, + ), + Row(crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.end, children: [ + Tooltip( + message: AppLocalizations.of(context)!.leaveConversation, + child: TextButton.icon( + onPressed: () { + showAlertDialog(context); + }, + style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.transparent)), + icon: Icon(CwtchIcons.leave_group), + label: Text( + AppLocalizations.of(context)!.leaveConversation, + style: TextStyle(decoration: TextDecoration.underline), + ), + )) ]) - ]), + ]) ]))))); }); }); @@ -246,10 +265,10 @@ class _PeerSettingsViewState extends State { child: Text(AppLocalizations.of(context)!.yesLeave), onPressed: () { var profileOnion = Provider.of(context, listen: false).profileOnion; - var handle = Provider.of(context, listen: false).identifier; + var identifier = Provider.of(context, listen: false).identifier; // locally update cache... - Provider.of(context, listen: false).isArchived = true; - Provider.of(context, listen: false).cwtch.DeleteContact(profileOnion, handle); + Provider.of(context, listen: false).contactList.removeContact(identifier); + Provider.of(context, listen: false).cwtch.DeleteContact(profileOnion, identifier); Future.delayed(Duration(milliseconds: 500), () { Provider.of(context, listen: false).selectedConversation = null; Navigator.of(context).popUntil((route) => route.settings.name == "conversations"); // dismiss dialog diff --git a/lib/widgets/contactrow.dart b/lib/widgets/contactrow.dart index cdad4f77..6cbc6ef7 100644 --- a/lib/widgets/contactrow.dart +++ b/lib/widgets/contactrow.dart @@ -50,7 +50,7 @@ class _ContactRowState extends State { badgeColor: Provider.of(context).theme.portraitContactBadgeColor, badgeTextColor: Provider.of(context).theme.portraitContactBadgeTextColor, diameter: 64.0, - imagePath: contact.imagePath, + imagePath: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) ? contact.imagePath : contact.defaultImagePath, maskOut: !contact.isOnline(), border: contact.isOnline() ? Provider.of(context).theme.portraitOnlineBorderColor diff --git a/lib/widgets/messagerow.dart b/lib/widgets/messagerow.dart index 577afb4f..ad92f840 100644 --- a/lib/widgets/messagerow.dart +++ b/lib/widgets/messagerow.dart @@ -152,6 +152,12 @@ class MessageRowState extends State with SingleTickerProviderStateMi ]; } else { var contact = Provider.of(context); + ContactInfoState? sender = Provider.of(context).contactList.findContact(Provider.of(context).senderHandle); + + String imagePath = Provider.of(context).senderImage!; + if (sender != null) { + imagePath = Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) ? sender.imagePath : sender.defaultImagePath; + } Widget wdgPortrait = GestureDetector( onTap: !isGroup ? null @@ -162,7 +168,8 @@ class MessageRowState extends State with SingleTickerProviderStateMi padding: EdgeInsets.all(4.0), child: ProfileImage( diameter: 48.0, - imagePath: Provider.of(context).senderImage ?? contact.imagePath, + // default to the contact image...otherwise use a derived sender image... + imagePath: imagePath, border: contact.status == "Authenticated" ? Provider.of(context).theme.portraitOnlineBorderColor : Provider.of(context).theme.portraitOfflineBorderColor, badgeTextColor: Colors.red, badgeColor: Colors.red, diff --git a/lib/widgets/profileimage.dart b/lib/widgets/profileimage.dart index f790a520..db2c7774 100644 --- a/lib/widgets/profileimage.dart +++ b/lib/widgets/profileimage.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:cwtch/themes/opaque.dart'; import 'package:provider/provider.dart'; @@ -6,7 +9,15 @@ import '../settings.dart'; class ProfileImage extends StatefulWidget { ProfileImage( - {required this.imagePath, required this.diameter, required this.border, this.badgeCount = 0, required this.badgeColor, required this.badgeTextColor, this.maskOut = false, this.tooltip = ""}); + {required this.imagePath, + required this.diameter, + required this.border, + this.badgeCount = 0, + required this.badgeColor, + required this.badgeTextColor, + this.maskOut = false, + this.tooltip = "", + this.badgeEdit = false}); final double diameter; final String imagePath; final Color border; @@ -14,6 +25,7 @@ class ProfileImage extends StatefulWidget { final Color badgeColor; final Color badgeTextColor; final bool maskOut; + final bool badgeEdit; final String tooltip; @override @@ -23,8 +35,11 @@ class ProfileImage extends StatefulWidget { class _ProfileImageState extends State { @override Widget build(BuildContext context) { - var image = Image( - image: AssetImage("assets/" + widget.imagePath), + var file = new File(widget.imagePath); + var image = Image.file( + file, + cacheWidth: 512, + cacheHeight: 512, filterQuality: FilterQuality.medium, // We need some theme specific blending here...we might want to consider making this a theme level attribute colorBlendMode: !widget.maskOut @@ -36,6 +51,21 @@ class _ProfileImageState extends State { isAntiAlias: true, width: widget.diameter, height: widget.diameter, + errorBuilder: (context, error, stackTrace) { + // on android the above will fail for asset images, in which case try to load them the original way + return Image.asset(widget.imagePath, + filterQuality: FilterQuality.medium, + // We need some theme specific blending here...we might want to consider making this a theme level attribute + colorBlendMode: !widget.maskOut + ? Provider.of(context).theme.mode == mode_dark + ? BlendMode.softLight + : BlendMode.darken + : BlendMode.srcOut, + color: Provider.of(context).theme.portraitBackgroundColor, + isAntiAlias: true, + width: widget.diameter, + height: widget.diameter); + }, ); return RepaintBoundary( @@ -50,14 +80,19 @@ class _ProfileImageState extends State { padding: const EdgeInsets.all(2.0), //border size child: ClipOval(clipBehavior: Clip.antiAlias, child: widget.tooltip == "" ? image : Tooltip(message: widget.tooltip, child: image))))), Visibility( - visible: widget.badgeCount > 0, + visible: widget.badgeEdit || widget.badgeCount > 0, child: Positioned( bottom: 0.0, right: 0.0, child: CircleAvatar( - radius: 10.0, + radius: max(10.0, widget.diameter / 6.0), backgroundColor: widget.badgeColor, - child: Text(widget.badgeCount > 99 ? "99+" : widget.badgeCount.toString(), style: TextStyle(color: widget.badgeTextColor, fontSize: 8.0)), + child: widget.badgeEdit + ? Icon( + Icons.edit, + color: widget.badgeTextColor, + ) + : Text(widget.badgeCount > 99 ? "99+" : widget.badgeCount.toString(), style: TextStyle(color: widget.badgeTextColor, fontSize: 8.0)), ), )), ])); diff --git a/lib/widgets/profilerow.dart b/lib/widgets/profilerow.dart index 770dbe54..9c2a576b 100644 --- a/lib/widgets/profilerow.dart +++ b/lib/widgets/profilerow.dart @@ -38,7 +38,7 @@ class _ProfileRowState extends State { badgeColor: Provider.of(context).theme.portraitProfileBadgeColor, badgeTextColor: Provider.of(context).theme.portraitProfileBadgeTextColor, diameter: 64.0, - imagePath: profile.imagePath, + imagePath: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) ? profile.imagePath : profile.defaultImagePath, border: profile.isOnline ? Provider.of(context).theme.portraitOnlineBorderColor : Provider.of(context).theme.portraitOfflineBorderColor)), Expanded( child: Column( @@ -105,11 +105,12 @@ class _ProfileRowState extends State { void _pushEditProfile({onion: "", displayName: "", profileImage: "", encrypted: true}) { Provider.of(context, listen: false).reset(); Navigator.of(context).push(MaterialPageRoute( - builder: (BuildContext context) { + builder: (BuildContext bcontext) { + var profile = Provider.of(bcontext).profs.getProfile(onion)!; return MultiProvider( providers: [ - ChangeNotifierProvider( - create: (_) => ProfileInfoState(onion: onion, nickname: displayName, imagePath: profileImage, encrypted: encrypted), + ChangeNotifierProvider.value( + value: profile, ), ], builder: (context, widget) => AddEditProfileView(), diff --git a/lib/widgets/textfield.dart b/lib/widgets/textfield.dart index 793077ff..0a9ffa6d 100644 --- a/lib/widgets/textfield.dart +++ b/lib/widgets/textfield.dart @@ -8,7 +8,8 @@ doNothing(String x) {} // Provides a styled Text Field for use in Form Widgets. // Callers must provide a text controller, label helper text and a validator. class CwtchTextField extends StatefulWidget { - CwtchTextField({required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey}); + CwtchTextField( + {required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey}); final TextEditingController controller; final String hintText; final FormFieldValidator? validator;