creation of groups works

This commit is contained in:
otsmr 2025-11-01 14:56:55 +01:00
parent 98a19b174b
commit 72dca4d4b4
24 changed files with 615 additions and 167 deletions

View file

@ -37,6 +37,11 @@ class ContactsDao extends DatabaseAccessor<TwonlyDB> with _$ContactsDaoMixin {
return select(contacts)..where((t) => t.userId.equals(userId));
}
Future<Contact?> getContactById(int userId) async {
return (select(contacts)..where((t) => t.userId.equals(userId)))
.getSingleOrNull();
}
Future<List<Contact>> getContactsByUsername(String username) async {
return (select(contacts)..where((t) => t.username.equals(username))).get();
}

View file

@ -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<TwonlyDB> 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<TwonlyDB> with _$GroupsDaoMixin {
await into(groupHistories).insert(insertAction);
}
Future<void> updateMember(
String groupId,
int contactId,
GroupMembersCompanion updates,
) async {
await (update(groupMembers)
..where(
(c) => c.groupId.equals(groupId) & c.contactId.equals(contactId)))
.write(updates);
}
Future<void> removeMember(String groupId, int contactId) async {
await (delete(groupMembers)
..where(
(c) => c.groupId.equals(groupId) & c.contactId.equals(contactId)))
.go();
}
Future<Group?> createNewDirectChat(
int contactId,
GroupsCompanion group,

View file

@ -399,6 +399,38 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.getSingleOrNull();
}
Stream<Future<List<(Message, Contact)>>> 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<void> deleteMessagesByContactId(int contactId) {
// return (delete(messages)
// ..where(

View file

@ -49,7 +49,7 @@ class Groups extends Table {
Set<Column> get primaryKey => {groupId};
}
enum MemberState { normal, admin }
enum MemberState { normal, admin, leftGroup }
@DataClassName('GroupMember')
class GroupMembers extends Table {

View file

@ -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<PlaintextContent_RetryErrorMessage> createRepeated() => $pb.PbList<PlaintextContent_RetryErrorMessage>();
@$core.pragma('dart2js:noInline')
static PlaintextContent_RetryErrorMessage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<PlaintextContent_RetryErrorMessage>(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<PlaintextContent_DecryptionErrorMessage>(1, _omitFieldNames ? '' : 'decryptionErrorMessage', protoName: 'decryptionErrorMessage', subBuilder: PlaintextContent_DecryptionErrorMessage.create)
..aOM<PlaintextContent_RetryErrorMessage>(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 {

View file

@ -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 = {

View file

@ -16,6 +16,9 @@ message Message {
message PlaintextContent {
optional DecryptionErrorMessage decryptionErrorMessage = 1;
optional RetryErrorMessage retryControlError = 2;
message RetryErrorMessage { }
message DecryptionErrorMessage {
enum Type {

View file

@ -485,11 +485,18 @@ class ApiService {
return sendRequestSync(req);
}
Future<Result> getUsername(int userId) async {
Future<Response_UserData?> 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<Result> downloadDone(List<int> token) async {

View file

@ -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<void> handleContactRequest(
Future<bool> handleContactRequest(
int fromUserId,
EncryptedContent_ContactRequest contactRequest,
) async {
@ -34,24 +34,23 @@ Future<void> 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;
final user = await apiService.getUserById(fromUserId);
if (user == null) {
return false;
}
await twonlyDB.contactsDao.insertOnConflictUpdate(
ContactsCompanion(
username: Value(utf8.decode(name)),
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<void> handleContactRequest(
),
);
}
return true;
}
Future<void> handleContactUpdate(

View file

@ -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<void> 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<void> 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<void> handleGroupUpdate(
@ -71,8 +83,23 @@ Future<void> handleGroupUpdate(
EncryptedContent_GroupUpdate update,
) async {}
Future<void> handleGroupJoin(
Future<bool> 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;
}

View file

@ -187,14 +187,18 @@ Future<void> insertAndSendTextMessage(
encryptedContent.textMessage.quoteMessageId = quotesMessageId;
}
await sendCipherTextToGroup(groupId, encryptedContent, message.messageId);
await sendCipherTextToGroup(
groupId,
encryptedContent,
messageId: message.messageId,
);
}
Future<void> 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());

View file

@ -74,11 +74,23 @@ Future<void> handleClient2ClientMessage(int fromUserId, Uint8List body) async {
await twonlyDB.receiptsDao.confirmReceipt(receiptId, fromUserId);
case Message_Type.PLAINTEXT_CONTENT:
if (message.hasPlaintextContent() &&
message.plaintextContent.hasDecryptionErrorMessage()) {
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<PlaintextContent?> 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<PlaintextContent?> 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<PlaintextContent?> 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<PlaintextContent?> 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,

View file

@ -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<bool> createNewGroup(String groupName, List<Contact> 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<bool> createNewGroup(String groupName, List<Contact> members) async {
stateEncryptionKey: Value(stateEncryptionKey),
stateVersionId: const Value(1),
myGroupPrivateKey: Value(myGroupKey.getPrivateKey().serialize()),
joinedGroup: const Value(true),
),
);
@ -125,7 +129,6 @@ Future<bool> createNewGroup(String groupName, List<Contact> members) async {
groupPublicKey: myGroupKey.getPublicKey().serialize(),
),
),
null,
);
return true;
@ -134,11 +137,15 @@ Future<bool> createNewGroup(String groupName, List<Contact> members) async {
Future<void> fetchGroupStatesForUnjoinedGroups() async {
final groups = await twonlyDB.groupsDao.getAllNotJoinedGroups();
for (final group in groups) {}
for (final group in groups) {
await fetchGroupState(group);
}
}
Future<GroupState?> fetchGroupState(Group group) async {
Future<bool> fetchGroupState(Group group) async {
try {
var isSuccess = true;
final response = await http
.get(
Uri.parse('${getGroupStateUrl()}/${group.groupId}'),
@ -149,7 +156,7 @@ Future<GroupState?> 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<GroupState?> 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<bool> 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;
}

View file

@ -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<Contact> contacts) {
return ChatItem._(lastOpenedPosition: contacts);
}
final Message? message;
final DateTime? date;
final List<Contact>? 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<ChatMessagesView> {
String currentInputText = '';
late StreamSubscription<Group?> userSub;
late StreamSubscription<List<Message>> messageSub;
late StreamSubscription<Future<List<(Message, Contact)>>>?
lastOpenedMessageByContactSub;
List<ChatItem> messages = [];
List<Message> allMessages = [];
List<(Message, Contact)> lastOpenedMessageByContact = [];
List<MemoryItem> galleryItems = [];
Message? quotesMessage;
GlobalKey verifyShieldKey = GlobalKey();
@ -87,6 +97,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
void dispose() {
userSub.cancel();
messageSub.cancel();
lastOpenedMessageByContactSub?.cancel();
tutorial?.cancel();
textFieldFocus.dispose();
super.dispose();
@ -103,14 +114,36 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
});
});
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 setMessages(update, lastOpenedMessageByContact);
});
});
}
Future<void> setMessages(
List<Message> newMessages,
List<(Message, Contact)> lastOpenedMessageByContact,
) async {
await flutterLocalNotificationsPlugin.cancelAll();
final chatItems = <ChatItem>[];
@ -119,8 +152,22 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
DateTime? lastDate;
final openedMessages = <int, List<String>>{};
final lastOpenedMessageToContact = <String, List<Contact>>{};
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) {
@ -142,6 +189,16 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
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) {
@ -159,8 +216,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
final items = await MemoryItem.convertFromMessages(storedMediaFiles);
galleryItems = items.values.toList();
setState(() {});
});
});
}
Future<void> _sendMessage() async {
@ -275,6 +330,18 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
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(

View file

@ -63,7 +63,6 @@ class _AllReactionsViewState extends State<AllReactionsView> {
remove: true,
),
),
null,
);
if (mounted) Navigator.pop(context);
}

View file

@ -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<ChatListEntry> {
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<ChatListEntry> {
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<ChatListEntry> {
if (combinesWidthNext) {
bottom = 0;
bottomLeft = 5.0;
hideContactAvatar = true;
}
if (message.senderId == null) {
@ -219,6 +238,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
final tmp2 = bottomLeft;
bottomLeft = bottomRight;
bottomRight = tmp2;
hideContactAvatar = true;
}
return (
@ -228,6 +248,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
topRight: Radius.circular(topRight),
bottomRight: Radius.circular(bottomRight),
bottomLeft: Radius.circular(bottomLeft),
)
),
hideContactAvatar
);
}

View file

@ -76,7 +76,6 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
targetMessageId: widget.message.messageId,
),
),
null,
);
await twonlyDB.messagesDao.updateMessageId(
widget.message.messageId,

View file

@ -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<void> editTextMessage(BuildContext context, Message message) async {
),
),
),
null,
);
}
if (!context.mounted) return;

View file

@ -290,7 +290,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
targetMessageId: currentMessage!.messageId,
),
),
null,
);
setState(() {
imageSaved = true;

View file

@ -47,7 +47,6 @@ class _EmojiReactionWidgetState extends State<EmojiReactionWidget> {
remove: false,
),
),
null,
);
setState(() {

View file

@ -122,7 +122,9 @@ class _MessageInfoViewState extends State<MessageInfoView> {
}
columns.add(
Row(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
AvatarIcon(
contact: groupMember.$2,
@ -151,6 +153,7 @@ class _MessageInfoViewState extends State<MessageInfoView> {
),
],
),
),
);
}
return columns;

View file

@ -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<AvatarIcon> {
_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<AvatarIcon> {
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<AvatarIcon> {
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),
),
),
),

View file

@ -46,9 +46,11 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
isBestFriend = gUser.myBestFriendGroupId == groupId;
final stream = twonlyDB.groupsDao.watchFlameCounter(groupId);
flameCounterSub = stream.listen((counter) {
if (mounted) {
setState(() {
flameCounter = counter;
});
}
});
}
}

View file

@ -36,6 +36,8 @@ class _GroupCreateSelectGroupNameViewState
await createNewGroup(textFieldGroupName.text, widget.selectedUsers);
if (wasSuccess) {
// POP
Navigator.popUntil(context, (route) => route.isFirst);
return;
}
setState(() {