From 72dca4d4b431f27065bde90ffc73a56bb62c7213 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 1 Nov 2025 14:56:55 +0100 Subject: [PATCH] creation of groups works --- lib/src/database/daos/contacts.dao.dart | 5 + lib/src/database/daos/groups.dao.dart | 26 ++- lib/src/database/daos/messages.dao.dart | 32 ++++ lib/src/database/tables/groups.table.dart | 2 +- .../client/generated/messages.pb.dart | 48 +++++ .../client/generated/messages.pbjson.dart | 19 +- lib/src/model/protobuf/client/messages.proto | 3 + lib/src/services/api.service.dart | 11 +- .../api/client2client/contact.c2c.dart | 28 +-- .../api/client2client/groups.c2c.dart | 33 +++- lib/src/services/api/messages.dart | 10 +- lib/src/services/api/server_messages.dart | 75 +++++--- lib/src/services/group.services.dart | 145 +++++++++++++-- lib/src/views/chats/chat_messages.view.dart | 169 ++++++++++++------ .../all_reactions.bottom_sheet.dart | 1 - .../chat_list_entry.dart | 33 +++- .../chat_media_entry.dart | 1 - .../message_context_menu.dart | 3 - lib/src/views/chats/media_viewer.view.dart | 1 - .../emoji_reactions_row.component.dart | 1 - lib/src/views/chats/message_info.view.dart | 49 ++--- .../components/avatar_icon.component.dart | 77 ++++++-- lib/src/views/components/flame.dart | 8 +- .../group_create_select_group_name.view.dart | 2 + 24 files changed, 615 insertions(+), 167 deletions(-) diff --git a/lib/src/database/daos/contacts.dao.dart b/lib/src/database/daos/contacts.dao.dart index 07a730e..7435688 100644 --- a/lib/src/database/daos/contacts.dao.dart +++ b/lib/src/database/daos/contacts.dao.dart @@ -37,6 +37,11 @@ class ContactsDao extends DatabaseAccessor with _$ContactsDaoMixin { return select(contacts)..where((t) => t.userId.equals(userId)); } + Future getContactById(int userId) async { + return (select(contacts)..where((t) => t.userId.equals(userId))) + .getSingleOrNull(); + } + Future> getContactsByUsername(String username) async { return (select(contacts)..where((t) => t.username.equals(username))).get(); } diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart index b28d460..52a837c 100644 --- a/lib/src/database/daos/groups.dao.dart +++ b/lib/src/database/daos/groups.dao.dart @@ -8,7 +8,13 @@ import 'package:twonly/src/utils/misc.dart'; part 'groups.dao.g.dart'; -@DriftAccessor(tables: [Groups, GroupMembers, GroupHistories]) +@DriftAccessor( + tables: [ + Groups, + GroupMembers, + GroupHistories, + ], +) class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { // this constructor is required so that the main database can create an instance // of this object. @@ -54,6 +60,24 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { await into(groupHistories).insert(insertAction); } + Future updateMember( + String groupId, + int contactId, + GroupMembersCompanion updates, + ) async { + await (update(groupMembers) + ..where( + (c) => c.groupId.equals(groupId) & c.contactId.equals(contactId))) + .write(updates); + } + + Future removeMember(String groupId, int contactId) async { + await (delete(groupMembers) + ..where( + (c) => c.groupId.equals(groupId) & c.contactId.equals(contactId))) + .go(); + } + Future createNewDirectChat( int contactId, GroupsCompanion group, diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 85b2928..f68163e 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -399,6 +399,38 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { .getSingleOrNull(); } + Stream>> watchLastOpenedMessagePerContact( + String groupId, + ) { + const sql = ''' + SELECT m.*, c.* + FROM ( + SELECT ma.contact_id, ma.message_id, + ROW_NUMBER() OVER (PARTITION BY ma.contact_id + ORDER BY ma.action_at DESC, ma.message_id DESC) AS rn + FROM message_actions ma + WHERE ma.type = 'openedAt' + ) last_open + JOIN messages m ON m.message_id = last_open.message_id + JOIN contacts c ON c.user_id = last_open.contact_id + WHERE last_open.rn = 1 AND m.group_id = ?; + '''; + + return customSelect( + sql, + variables: [Variable.withString(groupId)], + readsFrom: {messages, messageActions, contacts}, + ).watch().map((rows) async { + final res = <(Message, Contact)>[]; + for (final row in rows) { + final message = await messages.mapFromRow(row); + final contact = await contacts.mapFromRow(row); + res.add((message, contact)); + } + return res; + }); + } + // Future deleteMessagesByContactId(int contactId) { // return (delete(messages) // ..where( diff --git a/lib/src/database/tables/groups.table.dart b/lib/src/database/tables/groups.table.dart index e745572..68d248a 100644 --- a/lib/src/database/tables/groups.table.dart +++ b/lib/src/database/tables/groups.table.dart @@ -49,7 +49,7 @@ class Groups extends Table { Set get primaryKey => {groupId}; } -enum MemberState { normal, admin } +enum MemberState { normal, admin, leftGroup } @DataClassName('GroupMember') class GroupMembers extends Table { diff --git a/lib/src/model/protobuf/client/generated/messages.pb.dart b/lib/src/model/protobuf/client/generated/messages.pb.dart index 029b405..756be8b 100644 --- a/lib/src/model/protobuf/client/generated/messages.pb.dart +++ b/lib/src/model/protobuf/client/generated/messages.pb.dart @@ -112,6 +112,38 @@ class Message extends $pb.GeneratedMessage { PlaintextContent ensurePlaintextContent() => $_ensure(3); } +class PlaintextContent_RetryErrorMessage extends $pb.GeneratedMessage { + factory PlaintextContent_RetryErrorMessage() => create(); + PlaintextContent_RetryErrorMessage._() : super(); + factory PlaintextContent_RetryErrorMessage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory PlaintextContent_RetryErrorMessage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'PlaintextContent.RetryErrorMessage', createEmptyInstance: create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + PlaintextContent_RetryErrorMessage clone() => PlaintextContent_RetryErrorMessage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + PlaintextContent_RetryErrorMessage copyWith(void Function(PlaintextContent_RetryErrorMessage) updates) => super.copyWith((message) => updates(message as PlaintextContent_RetryErrorMessage)) as PlaintextContent_RetryErrorMessage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static PlaintextContent_RetryErrorMessage create() => PlaintextContent_RetryErrorMessage._(); + PlaintextContent_RetryErrorMessage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static PlaintextContent_RetryErrorMessage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static PlaintextContent_RetryErrorMessage? _defaultInstance; +} + class PlaintextContent_DecryptionErrorMessage extends $pb.GeneratedMessage { factory PlaintextContent_DecryptionErrorMessage({ PlaintextContent_DecryptionErrorMessage_Type? type, @@ -165,11 +197,15 @@ class PlaintextContent_DecryptionErrorMessage extends $pb.GeneratedMessage { class PlaintextContent extends $pb.GeneratedMessage { factory PlaintextContent({ PlaintextContent_DecryptionErrorMessage? decryptionErrorMessage, + PlaintextContent_RetryErrorMessage? retryControlError, }) { final $result = create(); if (decryptionErrorMessage != null) { $result.decryptionErrorMessage = decryptionErrorMessage; } + if (retryControlError != null) { + $result.retryControlError = retryControlError; + } return $result; } PlaintextContent._() : super(); @@ -178,6 +214,7 @@ class PlaintextContent extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'PlaintextContent', createEmptyInstance: create) ..aOM(1, _omitFieldNames ? '' : 'decryptionErrorMessage', protoName: 'decryptionErrorMessage', subBuilder: PlaintextContent_DecryptionErrorMessage.create) + ..aOM(2, _omitFieldNames ? '' : 'retryControlError', protoName: 'retryControlError', subBuilder: PlaintextContent_RetryErrorMessage.create) ..hasRequiredFields = false ; @@ -212,6 +249,17 @@ class PlaintextContent extends $pb.GeneratedMessage { void clearDecryptionErrorMessage() => clearField(1); @$pb.TagNumber(1) PlaintextContent_DecryptionErrorMessage ensureDecryptionErrorMessage() => $_ensure(0); + + @$pb.TagNumber(2) + PlaintextContent_RetryErrorMessage get retryControlError => $_getN(1); + @$pb.TagNumber(2) + set retryControlError(PlaintextContent_RetryErrorMessage v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasRetryControlError() => $_has(1); + @$pb.TagNumber(2) + void clearRetryControlError() => clearField(2); + @$pb.TagNumber(2) + PlaintextContent_RetryErrorMessage ensureRetryControlError() => $_ensure(1); } class EncryptedContent_GroupCreate extends $pb.GeneratedMessage { diff --git a/lib/src/model/protobuf/client/generated/messages.pbjson.dart b/lib/src/model/protobuf/client/generated/messages.pbjson.dart index 1896c58..82a3e7a 100644 --- a/lib/src/model/protobuf/client/generated/messages.pbjson.dart +++ b/lib/src/model/protobuf/client/generated/messages.pbjson.dart @@ -56,13 +56,20 @@ const PlaintextContent$json = { '1': 'PlaintextContent', '2': [ {'1': 'decryptionErrorMessage', '3': 1, '4': 1, '5': 11, '6': '.PlaintextContent.DecryptionErrorMessage', '9': 0, '10': 'decryptionErrorMessage', '17': true}, + {'1': 'retryControlError', '3': 2, '4': 1, '5': 11, '6': '.PlaintextContent.RetryErrorMessage', '9': 1, '10': 'retryControlError', '17': true}, ], - '3': [PlaintextContent_DecryptionErrorMessage$json], + '3': [PlaintextContent_RetryErrorMessage$json, PlaintextContent_DecryptionErrorMessage$json], '8': [ {'1': '_decryptionErrorMessage'}, + {'1': '_retryControlError'}, ], }; +@$core.Deprecated('Use plaintextContentDescriptor instead') +const PlaintextContent_RetryErrorMessage$json = { + '1': 'RetryErrorMessage', +}; + @$core.Deprecated('Use plaintextContentDescriptor instead') const PlaintextContent_DecryptionErrorMessage$json = { '1': 'DecryptionErrorMessage', @@ -85,10 +92,12 @@ const PlaintextContent_DecryptionErrorMessage_Type$json = { final $typed_data.Uint8List plaintextContentDescriptor = $convert.base64Decode( 'ChBQbGFpbnRleHRDb250ZW50EmUKFmRlY3J5cHRpb25FcnJvck1lc3NhZ2UYASABKAsyKC5QbG' 'FpbnRleHRDb250ZW50LkRlY3J5cHRpb25FcnJvck1lc3NhZ2VIAFIWZGVjcnlwdGlvbkVycm9y' - 'TWVzc2FnZYgBARqEAQoWRGVjcnlwdGlvbkVycm9yTWVzc2FnZRJBCgR0eXBlGAEgASgOMi0uUG' - 'xhaW50ZXh0Q29udGVudC5EZWNyeXB0aW9uRXJyb3JNZXNzYWdlLlR5cGVSBHR5cGUiJwoEVHlw' - 'ZRILCgdVTktOT1dOEAASEgoOUFJFS0VZX1VOS05PV04QAUIZChdfZGVjcnlwdGlvbkVycm9yTW' - 'Vzc2FnZQ=='); + 'TWVzc2FnZYgBARJWChFyZXRyeUNvbnRyb2xFcnJvchgCIAEoCzIjLlBsYWludGV4dENvbnRlbn' + 'QuUmV0cnlFcnJvck1lc3NhZ2VIAVIRcmV0cnlDb250cm9sRXJyb3KIAQEaEwoRUmV0cnlFcnJv' + 'ck1lc3NhZ2UahAEKFkRlY3J5cHRpb25FcnJvck1lc3NhZ2USQQoEdHlwZRgBIAEoDjItLlBsYW' + 'ludGV4dENvbnRlbnQuRGVjcnlwdGlvbkVycm9yTWVzc2FnZS5UeXBlUgR0eXBlIicKBFR5cGUS' + 'CwoHVU5LTk9XThAAEhIKDlBSRUtFWV9VTktOT1dOEAFCGQoXX2RlY3J5cHRpb25FcnJvck1lc3' + 'NhZ2VCFAoSX3JldHJ5Q29udHJvbEVycm9y'); @$core.Deprecated('Use encryptedContentDescriptor instead') const EncryptedContent$json = { diff --git a/lib/src/model/protobuf/client/messages.proto b/lib/src/model/protobuf/client/messages.proto index a4ab602..e47999f 100644 --- a/lib/src/model/protobuf/client/messages.proto +++ b/lib/src/model/protobuf/client/messages.proto @@ -16,6 +16,9 @@ message Message { message PlaintextContent { optional DecryptionErrorMessage decryptionErrorMessage = 1; + optional RetryErrorMessage retryControlError = 2; + + message RetryErrorMessage { } message DecryptionErrorMessage { enum Type { diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index 4c40c3b..35debca 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -485,11 +485,18 @@ class ApiService { return sendRequestSync(req); } - Future getUsername(int userId) async { + Future getUserById(int userId) async { final get = ApplicationData_GetUserById()..userId = Int64(userId); final appData = ApplicationData()..getuserbyid = get; final req = createClientToServerFromApplicationData(appData); - return sendRequestSync(req, contactId: userId); + final res = await sendRequestSync(req); + if (res.isSuccess) { + final ok = res.value as server.Response_Ok; + if (ok.hasUserdata()) { + return ok.userdata; + } + } + return null; } Future downloadDone(List token) async { diff --git a/lib/src/services/api/client2client/contact.c2c.dart b/lib/src/services/api/client2client/contact.c2c.dart index 8e5aaa3..a961536 100644 --- a/lib/src/services/api/client2client/contact.c2c.dart +++ b/lib/src/services/api/client2client/contact.c2c.dart @@ -13,7 +13,7 @@ import 'package:twonly/src/services/notifications/setup.notifications.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; -Future handleContactRequest( +Future handleContactRequest( int fromUserId, EncryptedContent_ContactRequest contactRequest, ) async { @@ -34,24 +34,23 @@ Future handleContactRequest( ), ), ); - return; + return true; } } // Request the username by the server so an attacker can not // forge the displayed username in the contact request - final username = await apiService.getUsername(fromUserId); - if (username.isSuccess) { - // ignore: avoid_dynamic_calls - final name = username.value.userdata.username as Uint8List; - await twonlyDB.contactsDao.insertOnConflictUpdate( - ContactsCompanion( - username: Value(utf8.decode(name)), - userId: Value(fromUserId), - requested: const Value(true), - deletedByUser: const Value(false), - ), - ); + final user = await apiService.getUserById(fromUserId); + if (user == null) { + return false; } + await twonlyDB.contactsDao.insertOnConflictUpdate( + ContactsCompanion( + username: Value(utf8.decode(user.username)), + userId: Value(fromUserId), + requested: const Value(true), + deletedByUser: const Value(false), + ), + ); await setupNotificationWithUsers(); case EncryptedContent_ContactRequest_Type.ACCEPT: Log.info('Got a contact accept from $fromUserId'); @@ -82,6 +81,7 @@ Future handleContactRequest( ), ); } + return true; } Future handleContactUpdate( diff --git a/lib/src/services/api/client2client/groups.c2c.dart b/lib/src/services/api/client2client/groups.c2c.dart index 0f7a040..6f81859 100644 --- a/lib/src/services/api/client2client/groups.c2c.dart +++ b/lib/src/services/api/client2client/groups.c2c.dart @@ -6,6 +6,7 @@ import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; +import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/group.services.dart'; import 'package:twonly/src/utils/log.dart'; @@ -26,9 +27,11 @@ Future handleGroupCreate( final group = await twonlyDB.groupsDao.createNewGroup( GroupsCompanion( groupId: Value(groupId), - stateVersionId: const Value(1), + stateVersionId: const Value(0), stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)), myGroupPrivateKey: Value(myGroupKey.getPrivateKey().serialize()), + groupName: const Value(''), + joinedGroup: const Value(false), ), ); @@ -63,6 +66,15 @@ Future handleGroupCreate( // can be done in the background -> websocket message can be ACK unawaited(fetchGroupStatesForUnjoinedGroups()); + + await sendCipherTextToGroup( + groupId, + EncryptedContent( + groupJoin: EncryptedContent_GroupJoin( + groupPublicKey: myGroupKey.getPublicKey().serialize(), + ), + ), + ); } Future handleGroupUpdate( @@ -71,8 +83,23 @@ Future handleGroupUpdate( EncryptedContent_GroupUpdate update, ) async {} -Future handleGroupJoin( +Future handleGroupJoin( int fromUserId, String groupId, EncryptedContent_GroupJoin join, -) async {} +) async { + if (await twonlyDB.contactsDao.getContactById(fromUserId) == null) { + if (!await addNewHiddenContact(fromUserId)) { + Log.error('Got group join, but could not load contact.'); + return false; + } + } + await twonlyDB.groupsDao.updateMember( + groupId, + fromUserId, + GroupMembersCompanion( + groupPublicKey: Value(Uint8List.fromList(join.groupPublicKey)), + ), + ); + return true; +} diff --git a/lib/src/services/api/messages.dart b/lib/src/services/api/messages.dart index 2345e02..4b72807 100644 --- a/lib/src/services/api/messages.dart +++ b/lib/src/services/api/messages.dart @@ -187,14 +187,18 @@ Future insertAndSendTextMessage( encryptedContent.textMessage.quoteMessageId = quotesMessageId; } - await sendCipherTextToGroup(groupId, encryptedContent, message.messageId); + await sendCipherTextToGroup( + groupId, + encryptedContent, + messageId: message.messageId, + ); } Future sendCipherTextToGroup( String groupId, - pb.EncryptedContent encryptedContent, + pb.EncryptedContent encryptedContent, { String? messageId, -) async { +}) async { final groupMembers = await twonlyDB.groupsDao.getGroupMembers(groupId); await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, DateTime.now()); diff --git a/lib/src/services/api/server_messages.dart b/lib/src/services/api/server_messages.dart index e9c8ddc..344a419 100644 --- a/lib/src/services/api/server_messages.dart +++ b/lib/src/services/api/server_messages.dart @@ -74,11 +74,23 @@ Future handleClient2ClientMessage(int fromUserId, Uint8List body) async { await twonlyDB.receiptsDao.confirmReceipt(receiptId, fromUserId); case Message_Type.PLAINTEXT_CONTENT: - if (message.hasPlaintextContent() && - message.plaintextContent.hasDecryptionErrorMessage()) { - Log.info( - 'Got decryption error: ${message.plaintextContent.decryptionErrorMessage.type} for $receiptId', - ); + var retry = false; + if (message.hasPlaintextContent()) { + if (message.plaintextContent.hasDecryptionErrorMessage()) { + Log.info( + 'Got decryption error: ${message.plaintextContent.decryptionErrorMessage.type} for $receiptId', + ); + retry = true; + } + if (message.plaintextContent.hasRetryControlError()) { + Log.info( + 'Got access control error for $receiptId. Resending message.', + ); + retry = true; + } + } + + if (retry) { final newReceiptId = uuid.v4(); await twonlyDB.receiptsDao.updateReceipt( receiptId, @@ -162,7 +174,10 @@ Future handleEncryptedMessage( final senderProfileCounter = await checkForProfileUpdate(fromUserId, content); if (content.hasContactRequest()) { - await handleContactRequest(fromUserId, content.contactRequest); + if (!await handleContactRequest(fromUserId, content.contactRequest)) { + return PlaintextContent() + ..retryControlError = PlaintextContent_RetryErrorMessage(); + } return null; } @@ -206,15 +221,6 @@ Future handleEncryptedMessage( return null; } - if (content.hasGroupUpdate()) { - await handleGroupUpdate( - fromUserId, - content.groupId, - content.groupUpdate, - ); - return null; - } - if (content.hasGroupCreate()) { await handleGroupCreate( fromUserId, @@ -224,15 +230,6 @@ Future handleEncryptedMessage( return null; } - if (content.hasGroupJoin()) { - await handleGroupJoin( - fromUserId, - content.groupId, - content.groupJoin, - ); - return null; - } - /// Verify that the user is (still) in that group... if (!await twonlyDB.groupsDao.isContactInGroup(fromUserId, content.groupId)) { if (getUUIDforDirectChat(gUser.userId, fromUserId) == content.groupId) { @@ -255,11 +252,41 @@ Future handleEncryptedMessage( ), ); } else { + if (content.hasGroupJoin()) { + Log.error( + 'Got group join message, but group does not exists yet, retry later. As probably the GroupCreate was not yet received.', + ); + // In case the group join was received before the GroupCreate the sender should send it later again. + return PlaintextContent() + ..retryControlError = PlaintextContent_RetryErrorMessage(); + } + Log.error('User $fromUserId tried to access group ${content.groupId}.'); return null; } } + if (content.hasGroupUpdate()) { + await handleGroupUpdate( + fromUserId, + content.groupId, + content.groupUpdate, + ); + return null; + } + + if (content.hasGroupJoin()) { + if (!await handleGroupJoin( + fromUserId, + content.groupId, + content.groupJoin, + )) { + return PlaintextContent() + ..retryControlError = PlaintextContent_RetryErrorMessage(); + } + return null; + } + if (content.hasTextMessage()) { await handleTextMessage( fromUserId, diff --git a/lib/src/services/group.services.dart b/lib/src/services/group.services.dart index c144208..201430a 100644 --- a/lib/src/services/group.services.dart +++ b/lib/src/services/group.services.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:drift/drift.dart' show Value; @@ -13,6 +15,7 @@ import 'package:twonly/src/model/protobuf/api/http/http_requests.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/groups.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'; import 'package:twonly/src/services/api/messages.dart'; +import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -29,7 +32,7 @@ Future createNewGroup(String groupName, List members) async { final memberIds = members.map((x) => Int64(x.userId)).toList(); final groupState = EncryptedGroupState( - memberIds: memberIds, + memberIds: [Int64(gUser.userId)] + memberIds, adminIds: [Int64(gUser.userId)], groupName: groupName, deleteMessagesAfterMilliseconds: @@ -88,6 +91,7 @@ Future createNewGroup(String groupName, List members) async { stateEncryptionKey: Value(stateEncryptionKey), stateVersionId: const Value(1), myGroupPrivateKey: Value(myGroupKey.getPrivateKey().serialize()), + joinedGroup: const Value(true), ), ); @@ -125,7 +129,6 @@ Future createNewGroup(String groupName, List members) async { groupPublicKey: myGroupKey.getPublicKey().serialize(), ), ), - null, ); return true; @@ -134,11 +137,15 @@ Future createNewGroup(String groupName, List members) async { Future fetchGroupStatesForUnjoinedGroups() async { final groups = await twonlyDB.groupsDao.getAllNotJoinedGroups(); - for (final group in groups) {} + for (final group in groups) { + await fetchGroupState(group); + } } -Future fetchGroupState(Group group) async { +Future fetchGroupState(Group group) async { try { + var isSuccess = true; + final response = await http .get( Uri.parse('${getGroupStateUrl()}/${group.groupId}'), @@ -149,7 +156,7 @@ Future fetchGroupState(Group group) async { Log.error( 'Could not load group state. Got status code ${response.statusCode} from server.', ); - return null; + return false; } final groupStateServer = GroupState.fromBuffer(response.bodyBytes); @@ -166,14 +173,128 @@ Future fetchGroupState(Group group) async { final encryptedGroupState = EncryptedGroupState.fromBuffer(encryptedGroupStateRaw); - encryptedGroupState.adminIds; - encryptedGroupState.memberIds; - encryptedGroupState.groupName; - encryptedGroupState.deleteMessagesAfterMilliseconds; - encryptedGroupState.deleteMessagesAfterMilliseconds; - groupStateServer.versionId; + if (group.stateVersionId >= groupStateServer.versionId.toInt()) { + Log.error('Group ${group.groupId} has newest group state'); + return false; + } + + final isGroupAdmin = encryptedGroupState.adminIds + .firstWhereOrNull((t) => t.toInt() == gUser.userId) != + null; + + await twonlyDB.groupsDao.updateGroup( + group.groupId, + GroupsCompanion( + groupName: Value(encryptedGroupState.groupName), + deleteMessagesAfterMilliseconds: Value( + encryptedGroupState.deleteMessagesAfterMilliseconds.toInt(), + ), + isGroupAdmin: Value(isGroupAdmin), + ), + ); + + var currentGroupMembers = + await twonlyDB.groupsDao.getGroupMembers(group.groupId); + + // First find and insert NEW members + for (final memberId in encryptedGroupState.memberIds) { + if (currentGroupMembers.any((t) => t.contactId == memberId.toInt())) { + // User is already in the database + continue; + } + Log.info('New member in the GROUP state: $memberId'); + + var inContacts = true; + + if (await twonlyDB.contactsDao.getContactById(memberId.toInt()) == null) { + // User is not yet in the contacts, add him in the hidden. So he is not in the contact list / needs to be + // requested separately. + if (!await addNewHiddenContact(memberId.toInt())) { + Log.error('Could not request member ID will retry later.'); + isSuccess = false; + inContacts = false; + } + } + if (inContacts) { + // User is already a contact, so just add him to the group members list + await twonlyDB.groupsDao.insertGroupMember( + GroupMembersCompanion( + groupId: Value(group.groupId), + contactId: Value(memberId.toInt()), + memberState: const Value(MemberState.normal), + ), + ); + } + } + + // check if there is a member which is not in the server list... + + // update the current members list + currentGroupMembers = + await twonlyDB.groupsDao.getGroupMembers(group.groupId); + + for (final member in currentGroupMembers) { + // Member is not any more in the members list + if (!encryptedGroupState.memberIds.contains(Int64(member.contactId))) { + await twonlyDB.groupsDao.removeMember(group.groupId, member.contactId); + continue; + } + + MemberState? newMemberState; + + if (encryptedGroupState.adminIds.contains(Int64(member.contactId))) { + if (member.memberState == MemberState.normal) { + // user was promoted + newMemberState = MemberState.admin; + } + } else if (member.memberState == MemberState.admin) { + // user was demoted + newMemberState = MemberState.normal; + } + + if (newMemberState != null) { + await twonlyDB.groupsDao.updateMember( + group.groupId, + member.contactId, + GroupMembersCompanion( + memberState: Value(newMemberState), + ), + ); + } + } + + if (isSuccess) { + // in case not all members could be loaded from the server, + // this will ensure it will be tried again later + await twonlyDB.groupsDao.updateGroup( + group.groupId, + GroupsCompanion( + stateVersionId: Value(groupStateServer.versionId.toInt()), + joinedGroup: const Value(true), + ), + ); + } + return true; } catch (e) { Log.error(e); - return null; + return false; } } + +Future addNewHiddenContact(int contactId) async { + final userData = await apiService.getUserById(contactId); + if (userData == null) { + Log.error('Could not load contact informations'); + return false; + } + await twonlyDB.contactsDao.insertOnConflictUpdate( + ContactsCompanion( + username: Value(utf8.decode(userData.username)), + userId: Value(contactId), + deletedByUser: + const Value(true), // this will hide the contact in the contact list + ), + ); + await createNewSignalSession(userData); + return true; +} diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index e7191dd..f753800 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -30,17 +30,22 @@ Color getMessageColor(Message message) { } class ChatItem { - const ChatItem._({this.message, this.date}); + const ChatItem._({this.message, this.date, this.lastOpenedPosition}); factory ChatItem.date(DateTime date) { return ChatItem._(date: date); } factory ChatItem.message(Message message) { return ChatItem._(message: message); } + factory ChatItem.lastOpenedPosition(List contacts) { + return ChatItem._(lastOpenedPosition: contacts); + } final Message? message; final DateTime? date; + final List? lastOpenedPosition; bool get isMessage => message != null; bool get isDate => date != null; + bool get isLastOpenedPosition => lastOpenedPosition != null; } /// Displays detailed information about a SampleItem. @@ -60,7 +65,12 @@ class _ChatMessagesViewState extends State { String currentInputText = ''; late StreamSubscription userSub; late StreamSubscription> messageSub; + late StreamSubscription>>? + lastOpenedMessageByContactSub; + List messages = []; + List allMessages = []; + List<(Message, Contact)> lastOpenedMessageByContact = []; List galleryItems = []; Message? quotesMessage; GlobalKey verifyShieldKey = GlobalKey(); @@ -87,6 +97,7 @@ class _ChatMessagesViewState extends State { void dispose() { userSub.cancel(); messageSub.cancel(); + lastOpenedMessageByContactSub?.cancel(); tutorial?.cancel(); textFieldFocus.dispose(); super.dispose(); @@ -103,66 +114,110 @@ class _ChatMessagesViewState extends State { }); }); + if (!widget.group.isDirectChat) { + final lastOpenedStream = + twonlyDB.messagesDao.watchLastOpenedMessagePerContact(group.groupId); + lastOpenedMessageByContactSub = + lastOpenedStream.listen((lastActionsFuture) async { + final update = await lastActionsFuture; + lastOpenedMessageByContact = update; + await setMessages(allMessages, update); + }); + } + final msgStream = twonlyDB.messagesDao.watchByGroupId(group.groupId); - messageSub = msgStream.listen((newMessages) async { + messageSub = msgStream.listen((update) async { + allMessages = update; + /// In case a message is not open yet the message is updated, which will trigger this watch to be called again. /// So as long as the Mutex is locked just return... if (protectMessageUpdating.isLocked) { - return; + // return; } await protectMessageUpdating.protect(() async { - await flutterLocalNotificationsPlugin.cancelAll(); - - final chatItems = []; - final storedMediaFiles = []; - - DateTime? lastDate; - - final openedMessages = >{}; - - for (final msg in newMessages) { - if (msg.type == MessageType.text && - msg.senderId != null && - msg.openedAt == null) { - if (openedMessages[msg.senderId!] == null) { - openedMessages[msg.senderId!] = []; - } - openedMessages[msg.senderId!]!.add(msg.messageId); - } - - if (msg.type == MessageType.media && msg.mediaStored) { - storedMediaFiles.add(msg); - } - - if (lastDate == null || - msg.createdAt.day != lastDate.day || - msg.createdAt.month != lastDate.month || - msg.createdAt.year != lastDate.year) { - chatItems.add(ChatItem.date(msg.createdAt)); - lastDate = msg.createdAt; - } - chatItems.add(ChatItem.message(msg)); - } - - for (final contactId in openedMessages.keys) { - await notifyContactAboutOpeningMessage( - contactId, - openedMessages[contactId]!, - ); - } - - if (!mounted) return; - setState(() { - messages = chatItems.reversed.toList(); - }); - - final items = await MemoryItem.convertFromMessages(storedMediaFiles); - galleryItems = items.values.toList(); - setState(() {}); + await setMessages(update, lastOpenedMessageByContact); }); }); } + Future setMessages( + List newMessages, + List<(Message, Contact)> lastOpenedMessageByContact, + ) async { + await flutterLocalNotificationsPlugin.cancelAll(); + + final chatItems = []; + final storedMediaFiles = []; + + DateTime? lastDate; + + final openedMessages = >{}; + final lastOpenedMessageToContact = >{}; + + final myLastMessageIndex = + newMessages.lastIndexWhere((t) => t.senderId == null); + + for (final opened in lastOpenedMessageByContact) { + if (!lastOpenedMessageToContact.containsKey(opened.$1.messageId)) { + lastOpenedMessageToContact[opened.$1.messageId] = [opened.$2]; + } else { + lastOpenedMessageToContact[opened.$1.messageId]!.add(opened.$2); + } + } + var index = 0; + + for (final msg in newMessages) { + index += 1; + if (msg.type == MessageType.text && + msg.senderId != null && + msg.openedAt == null) { + if (openedMessages[msg.senderId!] == null) { + openedMessages[msg.senderId!] = []; + } + openedMessages[msg.senderId!]!.add(msg.messageId); + } + + if (msg.type == MessageType.media && msg.mediaStored) { + storedMediaFiles.add(msg); + } + + if (lastDate == null || + msg.createdAt.day != lastDate.day || + msg.createdAt.month != lastDate.month || + msg.createdAt.year != lastDate.year) { + chatItems.add(ChatItem.date(msg.createdAt)); + lastDate = msg.createdAt; + } + chatItems.add(ChatItem.message(msg)); + + if (index <= myLastMessageIndex || index == newMessages.length) { + if (lastOpenedMessageToContact.containsKey(msg.messageId)) { + chatItems.add( + ChatItem.lastOpenedPosition( + lastOpenedMessageToContact[msg.messageId]!, + ), + ); + } + } + } + + for (final contactId in openedMessages.keys) { + await notifyContactAboutOpeningMessage( + contactId, + openedMessages[contactId]!, + ); + } + + if (!mounted) return; + setState(() { + messages = chatItems.reversed.toList(); + }); + + final items = await MemoryItem.convertFromMessages(storedMediaFiles); + galleryItems = items.values.toList(); + setState(() {}); + } + Future _sendMessage() async { if (newMessageController.text == '') return; @@ -275,6 +330,18 @@ class _ChatMessagesViewState extends State { return ChatDateChip( item: messages[i], ); + } else if (messages[i].isLastOpenedPosition) { + return Wrap( + spacing: 8, + alignment: WrapAlignment.center, + children: messages[i].lastOpenedPosition!.map((w) { + return AvatarIcon( + key: GlobalKey(), + contact: w, + fontSize: 12, + ); + }).toList(), + ); } else { final chatMessage = messages[i].message!; return Transform.translate( diff --git a/lib/src/views/chats/chat_messages_components/bottom_sheets/all_reactions.bottom_sheet.dart b/lib/src/views/chats/chat_messages_components/bottom_sheets/all_reactions.bottom_sheet.dart index 9d25e2d..8853f02 100644 --- a/lib/src/views/chats/chat_messages_components/bottom_sheets/all_reactions.bottom_sheet.dart +++ b/lib/src/views/chats/chat_messages_components/bottom_sheets/all_reactions.bottom_sheet.dart @@ -63,7 +63,6 @@ class _AllReactionsViewState extends State { remove: true, ), ), - null, ); if (mounted) Navigator.pop(context); } diff --git a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart index 4b8fd27..646609e 100644 --- a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart @@ -13,6 +13,7 @@ import 'package:twonly/src/views/chats/chat_messages_components/chat_text_entry. import 'package:twonly/src/views/chats/chat_messages_components/message_actions.dart'; import 'package:twonly/src/views/chats/chat_messages_components/message_context_menu.dart'; import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart'; +import 'package:twonly/src/views/components/avatar_icon.component.dart'; class ChatListEntry extends StatefulWidget { const ChatListEntry({ @@ -86,7 +87,7 @@ class _ChatListEntryState extends State { Widget build(BuildContext context) { final right = widget.message.senderId == null; - final (padding, borderRadius) = getMessageLayout( + final (padding, borderRadius, hideContactAvatar) = getMessageLayout( widget.message, widget.prevMessage, widget.nextMessage, @@ -172,19 +173,36 @@ class _ChatListEntryState extends State { return Align( alignment: right ? Alignment.centerRight : Alignment.centerLeft, - child: Padding(padding: padding, child: child), + child: Padding( + padding: padding, + child: Row( + mainAxisAlignment: + right ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!right && !widget.group.isDirectChat) + hideContactAvatar + ? const SizedBox(width: 24) + : AvatarIcon( + contactId: widget.message.senderId, + fontSize: 12, + ), + child, + ], + ), + ), ); } } -(EdgeInsetsGeometry, BorderRadius) getMessageLayout( +(EdgeInsetsGeometry, BorderRadius, bool) getMessageLayout( Message message, Message? prevMessage, Message? nextMessage, bool hasReactions, ) { - var bottom = 20.0; - var top = 0.0; + var bottom = 10.0; + var top = 10.0; + var hideContactAvatar = false; var topLeft = 12.0; var topRight = 12.0; @@ -209,6 +227,7 @@ class _ChatListEntryState extends State { if (combinesWidthNext) { bottom = 0; bottomLeft = 5.0; + hideContactAvatar = true; } if (message.senderId == null) { @@ -219,6 +238,7 @@ class _ChatListEntryState extends State { final tmp2 = bottomLeft; bottomLeft = bottomRight; bottomRight = tmp2; + hideContactAvatar = true; } return ( @@ -228,6 +248,7 @@ class _ChatListEntryState extends State { topRight: Radius.circular(topRight), bottomRight: Radius.circular(bottomRight), bottomLeft: Radius.circular(bottomLeft), - ) + ), + hideContactAvatar ); } diff --git a/lib/src/views/chats/chat_messages_components/chat_media_entry.dart b/lib/src/views/chats/chat_messages_components/chat_media_entry.dart index bb53503..96104b4 100644 --- a/lib/src/views/chats/chat_messages_components/chat_media_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_media_entry.dart @@ -76,7 +76,6 @@ class _ChatMediaEntryState extends State { targetMessageId: widget.message.messageId, ), ), - null, ); await twonlyDB.messagesDao.updateMessageId( widget.message.messageId, diff --git a/lib/src/views/chats/chat_messages_components/message_context_menu.dart b/lib/src/views/chats/chat_messages_components/message_context_menu.dart index 8fbfb26..3a95cc1 100644 --- a/lib/src/views/chats/chat_messages_components/message_context_menu.dart +++ b/lib/src/views/chats/chat_messages_components/message_context_menu.dart @@ -65,7 +65,6 @@ class MessageContextMenu extends StatelessWidget { remove: false, ), ), - null, ); }, icon: FontAwesomeIcons.faceLaugh, @@ -124,7 +123,6 @@ class MessageContextMenu extends StatelessWidget { senderMessageId: message.messageId, ), ), - null, ); } else { await twonlyDB.messagesDao @@ -225,7 +223,6 @@ Future editTextMessage(BuildContext context, Message message) async { ), ), ), - null, ); } if (!context.mounted) return; diff --git a/lib/src/views/chats/media_viewer.view.dart b/lib/src/views/chats/media_viewer.view.dart index c290f5f..41dc818 100644 --- a/lib/src/views/chats/media_viewer.view.dart +++ b/lib/src/views/chats/media_viewer.view.dart @@ -290,7 +290,6 @@ class _MediaViewerViewState extends State { targetMessageId: currentMessage!.messageId, ), ), - null, ); setState(() { imageSaved = true; diff --git a/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart b/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart index 5ff1e3c..d6fc8d3 100644 --- a/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart +++ b/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart @@ -47,7 +47,6 @@ class _EmojiReactionWidgetState extends State { remove: false, ), ), - null, ); setState(() { diff --git a/lib/src/views/chats/message_info.view.dart b/lib/src/views/chats/message_info.view.dart index e2b5acd..c4d81b6 100644 --- a/lib/src/views/chats/message_info.view.dart +++ b/lib/src/views/chats/message_info.view.dart @@ -122,34 +122,37 @@ class _MessageInfoViewState extends State { } columns.add( - Row( - children: [ - AvatarIcon( - contact: groupMember.$2, - fontSize: 15, - ), - const SizedBox(width: 6), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + AvatarIcon( + contact: groupMember.$2, + fontSize: 15, + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + getContactDisplayName(groupMember.$2), + style: const TextStyle(fontSize: 17), + ), + ], + ), + ), + Column( children: [ Text( - getContactDisplayName(groupMember.$2), - style: const TextStyle(fontSize: 17), + friendlyDateTime(context, actionAt), + style: const TextStyle(fontSize: 12), ), + Text(actionTypeText), ], ), - ), - Column( - children: [ - Text( - friendlyDateTime(context, actionAt), - style: const TextStyle(fontSize: 12), - ), - Text(actionTypeText), - ], - ), - ], + ], + ), ), ); } diff --git a/lib/src/views/components/avatar_icon.component.dart b/lib/src/views/components/avatar_icon.component.dart index 8b9924a..944cdba 100644 --- a/lib/src/views/components/avatar_icon.component.dart +++ b/lib/src/views/components/avatar_icon.component.dart @@ -3,7 +3,6 @@ import 'package:flutter_svg/svg.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/json/userdata.dart'; -import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; class AvatarIcon extends StatefulWidget { @@ -11,12 +10,14 @@ class AvatarIcon extends StatefulWidget { super.key, this.group, this.contact, + this.contactId, this.userData, this.fontSize = 20, this.color, }); final Group? group; final Contact? contact; + final int? contactId; final UserData? userData; final double? fontSize; final Color? color; @@ -54,6 +55,12 @@ class _AvatarIconState extends State { _avatarSVGs.add(widget.userData!.avatarSvg!); } else if (widget.contact?.avatarSvgCompressed != null) { _avatarSVGs.add(getAvatarSvg(widget.contact!.avatarSvgCompressed!)); + } else if (widget.contactId != null) { + final contact = + await twonlyDB.contactsDao.getContactById(widget.contactId!); + if (contact != null && contact.avatarSvgCompressed != null) { + _avatarSVGs.add(getAvatarSvg(contact.avatarSvgCompressed!)); + } } if (mounted) setState(() {}); } @@ -62,6 +69,62 @@ class _AvatarIconState extends State { Widget build(BuildContext context) { final proSize = (widget.fontSize == null) ? 40 : (widget.fontSize! * 2); + Widget avatars = SvgPicture.asset('assets/images/default_avatar.svg'); + + if (_avatarSVGs.length == 1) { + avatars = SvgPicture.string( + _avatarSVGs.first, + errorBuilder: (a, b, c) => avatars, + ); + } else if (_avatarSVGs.length >= 2) { + final a = SvgPicture.string( + _avatarSVGs.first, + errorBuilder: (a, b, c) => avatars, + ); + final b = SvgPicture.string( + _avatarSVGs[1], + errorBuilder: (a, b, c) => avatars, + ); + if (_avatarSVGs.length >= 3) { + final c = SvgPicture.string( + _avatarSVGs[2], + errorBuilder: (a, b, c) => avatars, + ); + avatars = Stack( + children: [ + Transform.translate( + offset: const Offset(-15, 5), + child: Transform.scale( + scale: 0.8, + child: c, + ), + ), + Transform.translate( + offset: const Offset(15, 5), + child: Transform.scale( + scale: 0.8, + child: b, + ), + ), + a, + ], + ); + } else { + avatars = Stack( + children: [ + Transform.translate( + offset: const Offset(-10, 5), + child: Transform.scale( + scale: 0.8, + child: b, + ), + ), + Transform.translate(offset: const Offset(10, 0), child: a), + ], + ); + } + } + return Container( constraints: BoxConstraints( minHeight: 2 * (widget.fontSize ?? 20), @@ -76,17 +139,7 @@ class _AvatarIconState extends State { height: proSize as double, width: proSize, color: widget.color, - child: Center( - child: _avatarSVGs.isEmpty - ? SvgPicture.asset('assets/images/default_avatar.svg') - : SvgPicture.string( - _avatarSVGs.first, - errorBuilder: (context, error, stackTrace) { - Log.error('$error'); - return Container(); - }, - ), - ), + child: Center(child: avatars), ), ), ), diff --git a/lib/src/views/components/flame.dart b/lib/src/views/components/flame.dart index 7512489..122d014 100644 --- a/lib/src/views/components/flame.dart +++ b/lib/src/views/components/flame.dart @@ -46,9 +46,11 @@ class _FlameCounterWidgetState extends State { isBestFriend = gUser.myBestFriendGroupId == groupId; final stream = twonlyDB.groupsDao.watchFlameCounter(groupId); flameCounterSub = stream.listen((counter) { - setState(() { - flameCounter = counter; - }); + if (mounted) { + setState(() { + flameCounter = counter; + }); + } }); } } diff --git a/lib/src/views/groups/group_create_select_group_name.view.dart b/lib/src/views/groups/group_create_select_group_name.view.dart index 7c4b81c..8b97054 100644 --- a/lib/src/views/groups/group_create_select_group_name.view.dart +++ b/lib/src/views/groups/group_create_select_group_name.view.dart @@ -36,6 +36,8 @@ class _GroupCreateSelectGroupNameViewState await createNewGroup(textFieldGroupName.text, widget.selectedUsers); if (wasSuccess) { // POP + Navigator.popUntil(context, (route) => route.isFirst); + return; } setState(() {