diff --git a/lib/cwtch/cwtchNotifier.dart b/lib/cwtch/cwtchNotifier.dart index ef270f90..74f395ab 100644 --- a/lib/cwtch/cwtchNotifier.dart +++ b/lib/cwtch/cwtchNotifier.dart @@ -143,25 +143,10 @@ class CwtchNotifier { var senderHandle = data['RemotePeer']; var senderImage = data['Picture']; var isAuto = data['Auto'] == "true"; + String? contenthash = data['ContentHash']; + var selectedConversation = appState.selectedProfile == data["ProfileOnion"] && appState.selectedConversation == identifier; - // We might not have received a contact created for this contact yet... - // In that case the **next** event we receive will actually update these values... - if (profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier) != null) { - if (appState.selectedProfile != data["ProfileOnion"] || appState.selectedConversation != identifier) { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.unreadMessages++; - } else { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.newMarker++; - } - profileCN.getProfile(data["ProfileOnion"])?.contactList.updateLastMessageTime(identifier, DateTime.now()); - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.updateMessageCache(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data["Data"]); - - // We only ever see messages from authenticated peers. - // If the contact is marked as offline then override this - can happen when the contact is removed from the front - // end during syncing. - if (profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.isOnline() == false) { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.status = "Authenticated"; - } - } + profileCN.getProfile(data["ProfileOnion"])?.contactList.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data["Data"], contenthash, selectedConversation, ); break; case "PeerAcknowledgement": @@ -200,18 +185,12 @@ class CwtchNotifier { var timestampSent = DateTime.tryParse(data['TimestampSent'])!; var currentTotal = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.totalMessages; var isAuto = data['Auto'] == "true"; + String? contenthash = data['ContentHash']; + var selectedConversation = appState.selectedProfile == data["ProfileOnion"] && appState.selectedConversation == identifier; + // Only bother to do anything if we know about the group and the provided index is greater than our current total... if (currentTotal != null && idx >= currentTotal) { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.updateMessageCache(identifier, idx, timestampSent, senderHandle, senderImage, isAuto, data["Data"]); - - //if not currently open - if (appState.selectedProfile != data["ProfileOnion"] || appState.selectedConversation != identifier) { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.unreadMessages++; - } else { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.newMarker++; - } - // TODO: There are 2 timestamps associated with a new group message - time sent and time received. // Sent refers to the time a profile alleges they sent a message // Received refers to the time we actually saw the message from the server @@ -222,7 +201,8 @@ class CwtchNotifier { // For now we perform some minimal checks on the sent timestamp to use to provide a useful ordering for honest contacts // and ensure that malicious contacts in groups can only set this timestamp to a value within the range of `last seen message time` // and `local now`. - profileCN.getProfile(data["ProfileOnion"])?.contactList.updateLastMessageTime(identifier, timestampSent.toLocal()); + profileCN.getProfile(data["ProfileOnion"])?.contactList.newMessage(identifier, idx, timestampSent, senderHandle, senderImage, isAuto, data["Data"], contenthash, selectedConversation); + notificationManager.notify("New Message From Group!"); } } else { diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 5f632df7..3793fb91 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -21,6 +21,8 @@ class ContactInfoState extends ChangeNotifier { late Map> keys; int _newMarker = 0; DateTime _newMarkerClearAt = DateTime.now(); + //late List messageCache; + late MessageCache messageCache; // todo: a nicer way to model contacts, groups and other "entities" late bool _isGroup; @@ -54,7 +56,8 @@ class ContactInfoState extends ChangeNotifier { this._lastMessageTime = lastMessageTime == null ? DateTime.fromMillisecondsSinceEpoch(0) : lastMessageTime; this._server = server; this._archived = archived; - this.messageCache = List.empty(growable: true); + //this.messageCache = List.empty(growable: true); + this.messageCache = new MessageCache(); keys = Map>(); } @@ -196,18 +199,31 @@ class ContactInfoState extends ChangeNotifier { return ret; } - void updateMessageCache(int conversation, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data) { - this.messageCache.insert(0, MessageInfo(MessageMetadata(profileOnion, conversation, messageID, timestamp, senderHandle, senderImage, "", {}, false, false, isAuto), data)); + void newMessage(int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedConversation) { + if (!selectedConversation) { + unreadMessages++; + } else { + newMarker++; + } + + this.messageCache.addNew(profileOnion, identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash); this.totalMessages += 1; + + // We only ever see messages from authenticated peers. + // If the contact is marked as offline then override this - can happen when the contact is removed from the front + // end during syncing. + if (isOnline() == false) { + status = "Authenticated"; + } } void bumpMessageCache() { - this.messageCache.insert(0, null); + this.messageCache.bumpMessageCache(); this.totalMessages += 1; } void ackCache(int messageID) { - this.messageCache.firstWhere((element) => element?.metadata.messageID == messageID)?.metadata.ackd = true; + this.messageCache.ackCache(messageID); notifyListeners(); } -} +} \ No newline at end of file diff --git a/lib/models/contactlist.dart b/lib/models/contactlist.dart index a00121d9..d54dfdf3 100644 --- a/lib/models/contactlist.dart +++ b/lib/models/contactlist.dart @@ -122,4 +122,9 @@ class ContactListState extends ChangeNotifier { int idx = _contacts.indexWhere((element) => element.onion == byHandle); return idx >= 0 ? _contacts[idx] : null; } -} + + void newMessage(int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedConversation) { + getContact(identifier)?.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, selectedConversation); + updateLastMessageTime(identifier, DateTime.now()); + } +} \ No newline at end of file diff --git a/lib/models/message.dart b/lib/models/message.dart index 91701cc8..82fa7cd2 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'package:cwtch/config.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import '../main.dart'; +import 'messagecache.dart'; import 'messages/filemessage.dart'; import 'messages/invitemessage.dart'; import 'messages/malformedmessage.dart'; @@ -28,7 +30,9 @@ const GroupConversationHandleLength = 32; abstract class Message { MessageMetadata getMetadata(); + Widget getWidget(BuildContext context, Key key); + Widget getPreviewWidget(BuildContext context); } @@ -57,48 +61,108 @@ Message compileOverlay(MessageMetadata metadata, String messageData) { } } -Future messageHandler(BuildContext context, String profileOnion, int conversationIdentifier, int index, {bool byID = false}) { +Future messageHandler(BuildContext context, String profileOnion, int conversationIdentifier, + {bool byIndex = false, int? index, bool byID = false, int? id, bool byHash = false, String? hash}) { + var malformedMetadata = MessageMetadata(profileOnion, conversationIdentifier, 0, DateTime.now(), "", "", "", {}, false, true, false); + if (!byIndex && !byID && !byHash) { + EnvironmentConfig.debugLog("Error calling messageHandler: one of byIndex, byID, byHash must be set"); + return Future.value(MalformedMessage(malformedMetadata)); + } + if ((byID && id == null) || (byIndex && index == null) || (byHash && hash == null)) { + EnvironmentConfig.debugLog("Error calling messageHandler: byType needs corresponding value and it was not set"); + return Future.value(MalformedMessage(malformedMetadata)); + } + + // Hit cache + MessageInfo? messageInfo = getMessageInfoFromCache(context, profileOnion, conversationIdentifier, byIndex: byIndex, index: index, byID: byID, id: id, byHash: byHash, hash: hash); + if (messageInfo != null) { + return Future.value(compileOverlay(messageInfo.metadata, messageInfo.wrapper)); + } + + // Fetch and Cache + var messageInfoFuture = fetchAndCacheMessageInfo(context, profileOnion, conversationIdentifier, byIndex: byIndex, index: index, byID: byID, id: id, byHash: byHash, hash: hash); + return messageInfoFuture.then( (MessageInfo? messageInfo) { + if (messageInfo != null) { + return compileOverlay(messageInfo.metadata, messageInfo.wrapper); + } else { + return MalformedMessage(malformedMetadata); + } + }); +} + +MessageInfo? getMessageInfoFromCache(BuildContext context, String profileOnion, int conversationIdentifier, +{bool byIndex = false, int? index, bool byID = false, int? id, bool byHash = false, String? hash}) { + // Hit cache try { var cache = Provider.of(context, listen: false).contactList.getContact(conversationIdentifier)?.messageCache; - if (cache != null && cache.length > index) { - if (cache[index] != null) { - return Future.value(compileOverlay(cache[index]!.metadata, cache[index]!.wrapper)); + if (cache != null) { + MessageInfo? messageInfo = null; + if (byID) { + messageInfo = cache.getById(id!); + } else if (byHash) { + messageInfo = cache.getByContentHash(hash!); + } else { + messageInfo = cache.getByIndex(index!); + } + if (messageInfo != null) { + return messageInfo; } } } catch (e) { + EnvironmentConfig.debugLog("message handler exception on get from cache: $e"); // provider check failed...make an expensive call... } + return null; +} +Future fetchAndCacheMessageInfo(BuildContext context, String profileOnion, int conversationIdentifier, +{bool byIndex = false, int? index, bool byID = false, int? id, bool byHash = false, String? hash}) { +// Load and cache try { Future rawMessageEnvelopeFuture; if (byID) { - rawMessageEnvelopeFuture = Provider.of(context, listen: false).cwtch.GetMessageByID(profileOnion, conversationIdentifier, index); + rawMessageEnvelopeFuture = Provider + .of(context, listen: false) + .cwtch + .GetMessageByID(profileOnion, conversationIdentifier, id!); + } else if (byHash) { + rawMessageEnvelopeFuture = Provider + .of(context, listen: false) + .cwtch + .GetMessageByContentHash(profileOnion, conversationIdentifier, hash!); } else { - rawMessageEnvelopeFuture = Provider.of(context, listen: false).cwtch.GetMessage(profileOnion, conversationIdentifier, index); + rawMessageEnvelopeFuture = Provider + .of(context, listen: false) + .cwtch + .GetMessage(profileOnion, conversationIdentifier, index!); } return rawMessageEnvelopeFuture.then((dynamic rawMessageEnvelope) { - var metadata = MessageMetadata(profileOnion, conversationIdentifier, index, DateTime.now(), "", "", "", {}, false, true, false); try { dynamic messageWrapper = jsonDecode(rawMessageEnvelope); - // There are 2 conditions in which this error condition can be met: - // 1. The application == nil, in which case this instance of the UI is already - // broken beyond repair, and will either be replaced by a new version, or requires a complete - // restart. - // 2. This index was incremented and we happened to fetch the timeline prior to the messages inclusion. - // This should be rare as Timeline addition/fetching is mutex protected and Dart itself will pipeline the - // calls to libCwtch-go - however because we use goroutines on the backend there is always a chance that one - // will find itself delayed. - // The second case is recoverable by tail-recursing this future. +// There are 2 conditions in which this error condition can be met: +// 1. The application == nil, in which case this instance of the UI is already +// broken beyond repair, and will either be replaced by a new version, or requires a complete +// restart. +// 2. This index was incremented and we happened to fetch the timeline prior to the messages inclusion. +// This should be rare as Timeline addition/fetching is mutex protected and Dart itself will pipeline the +// calls to libCwtch-go - however because we use goroutines on the backend there is always a chance that one +// will find itself delayed. +// The second case is recoverable by tail-recursing this future. if (messageWrapper['Message'] == null || messageWrapper['Message'] == '' || messageWrapper['Message'] == '{}') { return Future.delayed(Duration(seconds: 2), () { print("Tail recursive call to messageHandler called. This should be a rare event. If you see multiples of this log over a short period of time please log it as a bug."); - return messageHandler(context, profileOnion, conversationIdentifier, -1, byID: byID).then((value) => value); + return fetchAndCacheMessageInfo(context, profileOnion, conversationIdentifier, byIndex: byIndex, + index: index, + byID: byID, + id: id, + byHash: byHash, + hash: hash).then((value) => value); }); } - // Construct the initial metadata +// Construct the initial metadata var messageID = messageWrapper['ID']; var timestamp = DateTime.tryParse(messageWrapper['Timestamp'])!; var senderHandle = messageWrapper['PeerID']; @@ -107,16 +171,47 @@ Future messageHandler(BuildContext context, String profileOnion, int co var ackd = messageWrapper['Acknowledged']; var error = messageWrapper['Error'] != null; var signature = messageWrapper['Signature']; - metadata = MessageMetadata(profileOnion, conversationIdentifier, messageID, timestamp, senderHandle, senderImage, signature, attributes, ackd, error, false); + var contenthash = messageWrapper['ContentHash']; + var localIndex = messageWrapper['LocalIndex']; + var metadata = MessageMetadata( + profileOnion, + conversationIdentifier, + messageID, + timestamp, + senderHandle, + senderImage, + signature, + attributes, + ackd, + error, + false); + var messageInfo = new MessageInfo(metadata, messageWrapper['Message']); - return compileOverlay(metadata, messageWrapper['Message']); + var cache = Provider + .of(context, listen: false) + .contactList + .getContact(conversationIdentifier) + ?.messageCache; + + if (cache != null) { + if (byID) { + cache.addUnindexed(messageInfo, contenthash); + } else if (byHash) { + cache.addUnindexed(messageInfo, contenthash); + } else { + cache.add(messageInfo, index!, contenthash); + } + } + + return messageInfo; } catch (e) { - EnvironmentConfig.debugLog("an error! " + e.toString()); - return MalformedMessage(metadata); + EnvironmentConfig.debugLog("message handler exception on parse message and cache: " + e.toString()); + return null; } }); } catch (e) { - return Future.value(MalformedMessage(MessageMetadata(profileOnion, conversationIdentifier, -1, DateTime.now(), "", "", "", {}, false, true, false))); + EnvironmentConfig.debugLog("message handler exeption on get message: $e"); + return Future.value(null); } } @@ -139,12 +234,14 @@ class MessageMetadata extends ChangeNotifier { dynamic get attributes => this._attributes; bool get ackd => this._ackd; + set ackd(bool newVal) { this._ackd = newVal; notifyListeners(); } bool get error => this._error; + set error(bool newVal) { this._error = newVal; notifyListeners(); diff --git a/lib/models/messagecache.dart b/lib/models/messagecache.dart index 166d2d1f..3c11e526 100644 --- a/lib/models/messagecache.dart +++ b/lib/models/messagecache.dart @@ -9,21 +9,51 @@ class MessageInfo { class MessageCache { late Map cache; late List cacheByIndex; + late Map cacheByHash; MessageCache() { - this.cache = {}; - this.cacheByIndex = List.empty(growable: true); + cache = {}; + cacheByIndex = List.empty(growable: true); + cacheByHash = {}; } + int get indexedLength => cacheByIndex.length; - void addNew(int conversation, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data) { + MessageInfo? getById(int id) => cache[id]; + MessageInfo? getByIndex(int index) { + if (index >= cacheByIndex.length) { + return null; + } + return cache[cacheByIndex[index]]; + } + MessageInfo? getByContentHash(String contenthash) => cache[cacheByHash[contenthash]]; + + void addNew(String profileOnion, int conversation, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash) { this.cache[messageID] = MessageInfo(MessageMetadata(profileOnion, conversation, messageID, timestamp, senderHandle, senderImage, "", {}, false, false, isAuto), data); this.cacheByIndex.insert(0, messageID); + if (contenthash != null && contenthash != "") { + this.cacheByHash[contenthash] = messageID; + } } + void add(MessageInfo messageInfo, int index, String? contenthash) { + this.cache[messageInfo.metadata.messageID] = messageInfo; + this.cacheByIndex.insert(index, messageInfo.metadata.messageID); + if (contenthash != null && contenthash != "") { + this.cacheByHash[contenthash] = messageInfo.metadata.messageID; + } + } + + void addUnindexed(MessageInfo messageInfo, String? contenthash) { + this.cache[messageInfo.metadata.messageID] = messageInfo; + if (contenthash != null && contenthash != "") { + this.cacheByHash[contenthash] = messageInfo.metadata.messageID; + } + } + + // TODO inserting nulls travel down list causing fails for all void bumpMessageCache() { - this.messageCache.insert(0, null); - this.totalMessages += 1; + this.cacheByIndex.insert(0, null); } void ackCache(int messageID) { diff --git a/lib/models/messages/quotedmessage.dart b/lib/models/messages/quotedmessage.dart index 7d34c1b5..68615f18 100644 --- a/lib/models/messages/quotedmessage.dart +++ b/lib/models/messages/quotedmessage.dart @@ -9,6 +9,8 @@ import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import '../../main.dart'; +import '../messagecache.dart'; +import '../profile.dart'; class QuotedMessageStructure { final String quotedHash; @@ -21,22 +23,6 @@ class QuotedMessageStructure { }; } -class LocallyIndexedMessage { - final dynamic message; - final int index; - - LocallyIndexedMessage(this.message, this.index); - - LocallyIndexedMessage.fromJson(Map json) - : message = json['Message'], - index = json['LocalIndex']; - - Map toJson() => { - 'Message': message, - 'LocalIndex': index, - }; -} - class QuotedMessage extends Message { final MessageMetadata metadata; final String content; @@ -70,34 +56,11 @@ class QuotedMessage extends Message { return MalformedBubble(); } - var quotedMessagePotentials = Provider.of(context).cwtch.GetMessageByContentHash(metadata.profileOnion, metadata.conversationIdentifier, message["quotedHash"]); - Future quotedMessage = quotedMessagePotentials.then((matchingMessages) { - if (matchingMessages == "[]") { - return null; - } - // reverse order the messages from newest to oldest and return the - // first matching message where it's index is less than the index of this - // message - try { - var list = (jsonDecode(matchingMessages) as List).map((data) => LocallyIndexedMessage.fromJson(data)).toList(); - LocallyIndexedMessage candidate = list.reversed.first; - return candidate; - } catch (e) { - // Malformed Message will be returned... - return null; - } - }); - return ChangeNotifierProvider.value( value: this.metadata, builder: (bcontext, child) { return MessageRow( - QuotedMessageBubble(message["body"], quotedMessage.then((LocallyIndexedMessage? localIndex) { - if (localIndex != null) { - return messageHandler(bcontext, metadata.profileOnion, metadata.conversationIdentifier, localIndex.index); - } - return MalformedMessage(this.metadata); - })), + QuotedMessageBubble(message["body"], messageHandler(bcontext, metadata.profileOnion, metadata.conversationIdentifier, byHash: true, hash: message["quotedHash"])), key: key); }); } catch (e) { diff --git a/lib/models/profile.dart b/lib/models/profile.dart index a9191653..ddca5aba 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -132,7 +132,6 @@ class ProfileInfoState extends ChangeNotifier { @override void dispose() { super.dispose(); - print("profileinfostate.dispose()"); } void updateFrom(String onion, String name, String picture, String contactsJson, String serverJson, bool online) { diff --git a/lib/views/messageview.dart b/lib/views/messageview.dart index 877addb7..e2a22ba6 100644 --- a/lib/views/messageview.dart +++ b/lib/views/messageview.dart @@ -225,7 +225,10 @@ class _MessageViewState extends State { ctrlrCompose.clear(); focusNode.requestFocus(); Future.delayed(const Duration(milliseconds: 80), () { - Provider.of(context, listen: false).contactList.getContact(Provider.of(context, listen: false).identifier)?.bumpMessageCache(); + var profile = Provider.of(context, listen: false).profileOnion; + var identifier = Provider.of(context, listen: false).identifier; + //Provider.of(context, listen: false).contactList.getContact(Provider.of(context, listen: false).identifier)?.bumpMessageCache(); + fetchAndCacheMessageInfo(context, profile, identifier, byIndex: true, index: 0); 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()); @@ -282,7 +285,7 @@ class _MessageViewState extends State { 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), + messageHandler(context, Provider.of(context).selectedProfile!, Provider.of(context).selectedConversation!, id: Provider.of(context).selectedIndex!, byID: true), builder: (context, snapshot) { if (snapshot.hasData) { var message = snapshot.data! as Message; diff --git a/lib/widgets/messagelist.dart b/lib/widgets/messagelist.dart index aa29baf2..4756381d 100644 --- a/lib/widgets/messagelist.dart +++ b/lib/widgets/messagelist.dart @@ -83,7 +83,7 @@ class _MessageListState extends State { var messageIndex = index; return FutureBuilder( - future: messageHandler(outerContext, profileOnion, contactHandle, messageIndex), + future: messageHandler(outerContext, profileOnion, contactHandle, byIndex: true, index: messageIndex), builder: (context, snapshot) { if (snapshot.hasData) { var message = snapshot.data as Message; diff --git a/lib/widgets/messagerow.dart b/lib/widgets/messagerow.dart index 8661305d..f7b112fc 100644 --- a/lib/widgets/messagerow.dart +++ b/lib/widgets/messagerow.dart @@ -220,8 +220,8 @@ class MessageRowState extends State with SingleTickerProviderStateMi ))))); var mark = Provider.of(context).newMarker; if (mark > 0 && - Provider.of(context).messageCache.length > mark && - Provider.of(context).messageCache[mark - 1]?.metadata.messageID == Provider.of(context).messageID) { + Provider.of(context).messageCache.indexedLength > mark && + Provider.of(context).messageCache.getByIndex(mark - 1)?.metadata.messageID == Provider.of(context).messageID) { return Column(crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [Align(alignment: Alignment.center, child: _bubbleNew()), mr]); } else { return mr;