From 30086c2475dc1afe4f82e3b091dd29938dc04c38 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 1 Nov 2025 23:45:37 +0100 Subject: [PATCH] change group name possible #227 --- lib/src/database/daos/groups.dao.dart | 20 ++ lib/src/database/tables/groups.table.dart | 10 + lib/src/database/twonly.db.g.dart | 229 +++++++++++------- lib/src/localization/app_de.arb | 9 +- lib/src/localization/app_en.arb | 9 +- .../generated/app_localizations.dart | 42 ++++ .../generated/app_localizations_de.dart | 21 ++ .../generated/app_localizations_en.dart | 21 ++ .../api/client2client/groups.c2c.dart | 51 +++- lib/src/services/group.services.dart | 135 ++++++++++- .../chat_list_components/group_list_item.dart | 24 +- lib/src/views/chats/chat_messages.view.dart | 56 ++++- .../chat_group_action.dart | 84 +++++++ .../components/avatar_icon.component.dart | 1 + .../views/components/better_list_title.dart | 21 +- .../components/context_menu.component.dart | 4 +- lib/src/views/groups/group.view.dart | 218 ++++++++++++++++- .../views/groups/group_member.context.dart | 83 +++++++ 18 files changed, 923 insertions(+), 115 deletions(-) create mode 100644 lib/src/views/chats/chat_messages_components/chat_group_action.dart create mode 100644 lib/src/views/groups/group_member.context.dart diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart index 60e0d37..b1da33f 100644 --- a/lib/src/database/daos/groups.dao.dart +++ b/lib/src/database/daos/groups.dao.dart @@ -60,6 +60,13 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { await into(groupHistories).insert(insertAction); } + Stream> watchGroupActions(String groupId) { + return (select(groupHistories) + ..where((t) => t.groupId.equals(groupId)) + ..orderBy([(t) => OrderingTerm.asc(t.actionAt)])) + .watch(); + } + Future updateMember( String groupId, int contactId, @@ -139,6 +146,19 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { return query.map((row) => row.readTable(contacts)).watch(); } + Stream> watchGroupMembers(String groupId) { + final query = + (select(groupMembers)..where((t) => t.groupId.equals(groupId))).join([ + leftOuterJoin( + contacts, + contacts.userId.equalsExp(groupMembers.contactId), + ), + ]); + return query + .map((row) => (row.readTable(contacts), row.readTable(groupMembers))) + .watch(); + } + Stream> watchGroups() { return select(groups).watch(); } diff --git a/lib/src/database/tables/groups.table.dart b/lib/src/database/tables/groups.table.dart index 68d248a..ea25e46 100644 --- a/lib/src/database/tables/groups.table.dart +++ b/lib/src/database/tables/groups.table.dart @@ -82,6 +82,9 @@ class GroupHistories extends Table { TextColumn get groupId => text().references(Groups, #groupId, onDelete: KeyAction.cascade)(); + IntColumn get contactId => + integer().nullable().references(Contacts, #userId)(); + IntColumn get affectedContactId => integer().nullable().references(Contacts, #userId)(); @@ -95,3 +98,10 @@ class GroupHistories extends Table { @override Set get primaryKey => {groupHistoryId}; } + +GroupActionType? groupActionTypeFromString(String name) { + for (final v in GroupActionType.values) { + if (v.name == name) return v; + } + return null; +} diff --git a/lib/src/database/twonly.db.g.dart b/lib/src/database/twonly.db.g.dart index 76dd93a..f1589fe 100644 --- a/lib/src/database/twonly.db.g.dart +++ b/lib/src/database/twonly.db.g.dart @@ -6880,6 +6880,15 @@ class $GroupHistoriesTable extends GroupHistories requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways( 'REFERENCES "groups" (group_id) ON DELETE CASCADE')); + static const VerificationMeta _contactIdMeta = + const VerificationMeta('contactId'); + @override + late final GeneratedColumn contactId = GeneratedColumn( + 'contact_id', aliasedName, true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('REFERENCES contacts (user_id)')); static const VerificationMeta _affectedContactIdMeta = const VerificationMeta('affectedContactId'); @override @@ -6918,6 +6927,7 @@ class $GroupHistoriesTable extends GroupHistories List get $columns => [ groupHistoryId, groupId, + contactId, affectedContactId, oldGroupName, newGroupName, @@ -6948,6 +6958,10 @@ class $GroupHistoriesTable extends GroupHistories } else if (isInserting) { context.missing(_groupIdMeta); } + if (data.containsKey('contact_id')) { + context.handle(_contactIdMeta, + contactId.isAcceptableOrUnknown(data['contact_id']!, _contactIdMeta)); + } if (data.containsKey('affected_contact_id')) { context.handle( _affectedContactIdMeta, @@ -6983,6 +6997,8 @@ class $GroupHistoriesTable extends GroupHistories DriftSqlType.string, data['${effectivePrefix}group_history_id'])!, groupId: attachedDatabase.typeMapping .read(DriftSqlType.string, data['${effectivePrefix}group_id'])!, + contactId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}contact_id']), affectedContactId: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}affected_contact_id']), oldGroupName: attachedDatabase.typeMapping @@ -7009,6 +7025,7 @@ class $GroupHistoriesTable extends GroupHistories class GroupHistory extends DataClass implements Insertable { final String groupHistoryId; final String groupId; + final int? contactId; final int? affectedContactId; final String? oldGroupName; final String? newGroupName; @@ -7017,6 +7034,7 @@ class GroupHistory extends DataClass implements Insertable { const GroupHistory( {required this.groupHistoryId, required this.groupId, + this.contactId, this.affectedContactId, this.oldGroupName, this.newGroupName, @@ -7027,6 +7045,9 @@ class GroupHistory extends DataClass implements Insertable { final map = {}; map['group_history_id'] = Variable(groupHistoryId); map['group_id'] = Variable(groupId); + if (!nullToAbsent || contactId != null) { + map['contact_id'] = Variable(contactId); + } if (!nullToAbsent || affectedContactId != null) { map['affected_contact_id'] = Variable(affectedContactId); } @@ -7048,6 +7069,9 @@ class GroupHistory extends DataClass implements Insertable { return GroupHistoriesCompanion( groupHistoryId: Value(groupHistoryId), groupId: Value(groupId), + contactId: contactId == null && nullToAbsent + ? const Value.absent() + : Value(contactId), affectedContactId: affectedContactId == null && nullToAbsent ? const Value.absent() : Value(affectedContactId), @@ -7068,6 +7092,7 @@ class GroupHistory extends DataClass implements Insertable { return GroupHistory( groupHistoryId: serializer.fromJson(json['groupHistoryId']), groupId: serializer.fromJson(json['groupId']), + contactId: serializer.fromJson(json['contactId']), affectedContactId: serializer.fromJson(json['affectedContactId']), oldGroupName: serializer.fromJson(json['oldGroupName']), newGroupName: serializer.fromJson(json['newGroupName']), @@ -7082,6 +7107,7 @@ class GroupHistory extends DataClass implements Insertable { return { 'groupHistoryId': serializer.toJson(groupHistoryId), 'groupId': serializer.toJson(groupId), + 'contactId': serializer.toJson(contactId), 'affectedContactId': serializer.toJson(affectedContactId), 'oldGroupName': serializer.toJson(oldGroupName), 'newGroupName': serializer.toJson(newGroupName), @@ -7094,6 +7120,7 @@ class GroupHistory extends DataClass implements Insertable { GroupHistory copyWith( {String? groupHistoryId, String? groupId, + Value contactId = const Value.absent(), Value affectedContactId = const Value.absent(), Value oldGroupName = const Value.absent(), Value newGroupName = const Value.absent(), @@ -7102,6 +7129,7 @@ class GroupHistory extends DataClass implements Insertable { GroupHistory( groupHistoryId: groupHistoryId ?? this.groupHistoryId, groupId: groupId ?? this.groupId, + contactId: contactId.present ? contactId.value : this.contactId, affectedContactId: affectedContactId.present ? affectedContactId.value : this.affectedContactId, @@ -7118,6 +7146,7 @@ class GroupHistory extends DataClass implements Insertable { ? data.groupHistoryId.value : this.groupHistoryId, groupId: data.groupId.present ? data.groupId.value : this.groupId, + contactId: data.contactId.present ? data.contactId.value : this.contactId, affectedContactId: data.affectedContactId.present ? data.affectedContactId.value : this.affectedContactId, @@ -7137,6 +7166,7 @@ class GroupHistory extends DataClass implements Insertable { return (StringBuffer('GroupHistory(') ..write('groupHistoryId: $groupHistoryId, ') ..write('groupId: $groupId, ') + ..write('contactId: $contactId, ') ..write('affectedContactId: $affectedContactId, ') ..write('oldGroupName: $oldGroupName, ') ..write('newGroupName: $newGroupName, ') @@ -7147,14 +7177,15 @@ class GroupHistory extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(groupHistoryId, groupId, affectedContactId, - oldGroupName, newGroupName, type, actionAt); + int get hashCode => Object.hash(groupHistoryId, groupId, contactId, + affectedContactId, oldGroupName, newGroupName, type, actionAt); @override bool operator ==(Object other) => identical(this, other) || (other is GroupHistory && other.groupHistoryId == this.groupHistoryId && other.groupId == this.groupId && + other.contactId == this.contactId && other.affectedContactId == this.affectedContactId && other.oldGroupName == this.oldGroupName && other.newGroupName == this.newGroupName && @@ -7165,6 +7196,7 @@ class GroupHistory extends DataClass implements Insertable { class GroupHistoriesCompanion extends UpdateCompanion { final Value groupHistoryId; final Value groupId; + final Value contactId; final Value affectedContactId; final Value oldGroupName; final Value newGroupName; @@ -7174,6 +7206,7 @@ class GroupHistoriesCompanion extends UpdateCompanion { const GroupHistoriesCompanion({ this.groupHistoryId = const Value.absent(), this.groupId = const Value.absent(), + this.contactId = const Value.absent(), this.affectedContactId = const Value.absent(), this.oldGroupName = const Value.absent(), this.newGroupName = const Value.absent(), @@ -7184,6 +7217,7 @@ class GroupHistoriesCompanion extends UpdateCompanion { GroupHistoriesCompanion.insert({ required String groupHistoryId, required String groupId, + this.contactId = const Value.absent(), this.affectedContactId = const Value.absent(), this.oldGroupName = const Value.absent(), this.newGroupName = const Value.absent(), @@ -7196,6 +7230,7 @@ class GroupHistoriesCompanion extends UpdateCompanion { static Insertable custom({ Expression? groupHistoryId, Expression? groupId, + Expression? contactId, Expression? affectedContactId, Expression? oldGroupName, Expression? newGroupName, @@ -7206,6 +7241,7 @@ class GroupHistoriesCompanion extends UpdateCompanion { return RawValuesInsertable({ if (groupHistoryId != null) 'group_history_id': groupHistoryId, if (groupId != null) 'group_id': groupId, + if (contactId != null) 'contact_id': contactId, if (affectedContactId != null) 'affected_contact_id': affectedContactId, if (oldGroupName != null) 'old_group_name': oldGroupName, if (newGroupName != null) 'new_group_name': newGroupName, @@ -7218,6 +7254,7 @@ class GroupHistoriesCompanion extends UpdateCompanion { GroupHistoriesCompanion copyWith( {Value? groupHistoryId, Value? groupId, + Value? contactId, Value? affectedContactId, Value? oldGroupName, Value? newGroupName, @@ -7227,6 +7264,7 @@ class GroupHistoriesCompanion extends UpdateCompanion { return GroupHistoriesCompanion( groupHistoryId: groupHistoryId ?? this.groupHistoryId, groupId: groupId ?? this.groupId, + contactId: contactId ?? this.contactId, affectedContactId: affectedContactId ?? this.affectedContactId, oldGroupName: oldGroupName ?? this.oldGroupName, newGroupName: newGroupName ?? this.newGroupName, @@ -7245,6 +7283,9 @@ class GroupHistoriesCompanion extends UpdateCompanion { if (groupId.present) { map['group_id'] = Variable(groupId.value); } + if (contactId.present) { + map['contact_id'] = Variable(contactId.value); + } if (affectedContactId.present) { map['affected_contact_id'] = Variable(affectedContactId.value); } @@ -7272,6 +7313,7 @@ class GroupHistoriesCompanion extends UpdateCompanion { return (StringBuffer('GroupHistoriesCompanion(') ..write('groupHistoryId: $groupHistoryId, ') ..write('groupId: $groupId, ') + ..write('contactId: $contactId, ') ..write('affectedContactId: $affectedContactId, ') ..write('oldGroupName: $oldGroupName, ') ..write('newGroupName: $newGroupName, ') @@ -7568,22 +7610,6 @@ final class $$ContactsTableReferences return ProcessedTableManager( manager.$state.copyWith(prefetchedData: cache)); } - - static MultiTypedResultKey<$GroupHistoriesTable, List> - _groupHistoriesRefsTable(_$TwonlyDB db) => - MultiTypedResultKey.fromTable(db.groupHistories, - aliasName: $_aliasNameGenerator( - db.contacts.userId, db.groupHistories.affectedContactId)); - - $$GroupHistoriesTableProcessedTableManager get groupHistoriesRefs { - final manager = $$GroupHistoriesTableTableManager($_db, $_db.groupHistories) - .filter((f) => f.affectedContactId.userId - .sqlEquals($_itemColumn('user_id')!)); - - final cache = $_typedResult.readTableOrNull(_groupHistoriesRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); - } } class $$ContactsTableFilterComposer @@ -7766,27 +7792,6 @@ class $$ContactsTableFilterComposer )); return f(composer); } - - Expression groupHistoriesRefs( - Expression Function($$GroupHistoriesTableFilterComposer f) f) { - final $$GroupHistoriesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.userId, - referencedTable: $db.groupHistories, - getReferencedColumn: (t) => t.affectedContactId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$GroupHistoriesTableFilterComposer( - $db: $db, - $table: $db.groupHistories, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); - return f(composer); - } } class $$ContactsTableOrderingComposer @@ -8020,27 +8025,6 @@ class $$ContactsTableAnnotationComposer )); return f(composer); } - - Expression groupHistoriesRefs( - Expression Function($$GroupHistoriesTableAnnotationComposer a) f) { - final $$GroupHistoriesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.userId, - referencedTable: $db.groupHistories, - getReferencedColumn: (t) => t.affectedContactId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$GroupHistoriesTableAnnotationComposer( - $db: $db, - $table: $db.groupHistories, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); - return f(composer); - } } class $$ContactsTableTableManager extends RootTableManager< @@ -8060,8 +8044,7 @@ class $$ContactsTableTableManager extends RootTableManager< bool groupMembersRefs, bool receiptsRefs, bool signalContactPreKeysRefs, - bool signalContactSignedPreKeysRefs, - bool groupHistoriesRefs})> { + bool signalContactSignedPreKeysRefs})> { $$ContactsTableTableManager(_$TwonlyDB db, $ContactsTable table) : super(TableManagerState( db: db, @@ -8142,8 +8125,7 @@ class $$ContactsTableTableManager extends RootTableManager< groupMembersRefs = false, receiptsRefs = false, signalContactPreKeysRefs = false, - signalContactSignedPreKeysRefs = false, - groupHistoriesRefs = false}) { + signalContactSignedPreKeysRefs = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ @@ -8153,8 +8135,7 @@ class $$ContactsTableTableManager extends RootTableManager< if (receiptsRefs) db.receipts, if (signalContactPreKeysRefs) db.signalContactPreKeys, if (signalContactSignedPreKeysRefs) - db.signalContactSignedPreKeys, - if (groupHistoriesRefs) db.groupHistories + db.signalContactSignedPreKeys ], addJoins: null, getPrefetchedDataCallback: (items) async { @@ -8234,19 +8215,6 @@ class $$ContactsTableTableManager extends RootTableManager< referencedItemsForCurrentItem: (item, referencedItems) => referencedItems .where((e) => e.contactId == item.userId), - typedResults: items), - if (groupHistoriesRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: $$ContactsTableReferences - ._groupHistoriesRefsTable(db), - managerFromTypedResult: (p0) => - $$ContactsTableReferences(db, table, p0) - .groupHistoriesRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.affectedContactId == item.userId), typedResults: items) ]; }, @@ -8272,8 +8240,7 @@ typedef $$ContactsTableProcessedTableManager = ProcessedTableManager< bool groupMembersRefs, bool receiptsRefs, bool signalContactPreKeysRefs, - bool signalContactSignedPreKeysRefs, - bool groupHistoriesRefs})>; + bool signalContactSignedPreKeysRefs})>; typedef $$GroupsTableCreateCompanionBuilder = GroupsCompanion Function({ required String groupId, Value isGroupAdmin, @@ -13213,6 +13180,7 @@ typedef $$GroupHistoriesTableCreateCompanionBuilder = GroupHistoriesCompanion Function({ required String groupHistoryId, required String groupId, + Value contactId, Value affectedContactId, Value oldGroupName, Value newGroupName, @@ -13224,6 +13192,7 @@ typedef $$GroupHistoriesTableUpdateCompanionBuilder = GroupHistoriesCompanion Function({ Value groupHistoryId, Value groupId, + Value contactId, Value affectedContactId, Value oldGroupName, Value newGroupName, @@ -13251,6 +13220,21 @@ final class $$GroupHistoriesTableReferences manager.$state.copyWith(prefetchedData: [item])); } + static $ContactsTable _contactIdTable(_$TwonlyDB db) => + db.contacts.createAlias($_aliasNameGenerator( + db.groupHistories.contactId, db.contacts.userId)); + + $$ContactsTableProcessedTableManager? get contactId { + final $_column = $_itemColumn('contact_id'); + if ($_column == null) return null; + final manager = $$ContactsTableTableManager($_db, $_db.contacts) + .filter((f) => f.userId.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_contactIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } + static $ContactsTable _affectedContactIdTable(_$TwonlyDB db) => db.contacts.createAlias($_aliasNameGenerator( db.groupHistories.affectedContactId, db.contacts.userId)); @@ -13314,6 +13298,26 @@ class $$GroupHistoriesTableFilterComposer return composer; } + $$ContactsTableFilterComposer get contactId { + final $$ContactsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.contactId, + referencedTable: $db.contacts, + getReferencedColumn: (t) => t.userId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$ContactsTableFilterComposer( + $db: $db, + $table: $db.contacts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + $$ContactsTableFilterComposer get affectedContactId { final $$ContactsTableFilterComposer composer = $composerBuilder( composer: this, @@ -13382,6 +13386,26 @@ class $$GroupHistoriesTableOrderingComposer return composer; } + $$ContactsTableOrderingComposer get contactId { + final $$ContactsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.contactId, + referencedTable: $db.contacts, + getReferencedColumn: (t) => t.userId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$ContactsTableOrderingComposer( + $db: $db, + $table: $db.contacts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + $$ContactsTableOrderingComposer get affectedContactId { final $$ContactsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -13447,6 +13471,26 @@ class $$GroupHistoriesTableAnnotationComposer return composer; } + $$ContactsTableAnnotationComposer get contactId { + final $$ContactsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.contactId, + referencedTable: $db.contacts, + getReferencedColumn: (t) => t.userId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$ContactsTableAnnotationComposer( + $db: $db, + $table: $db.contacts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + $$ContactsTableAnnotationComposer get affectedContactId { final $$ContactsTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -13479,7 +13523,8 @@ class $$GroupHistoriesTableTableManager extends RootTableManager< $$GroupHistoriesTableUpdateCompanionBuilder, (GroupHistory, $$GroupHistoriesTableReferences), GroupHistory, - PrefetchHooks Function({bool groupId, bool affectedContactId})> { + PrefetchHooks Function( + {bool groupId, bool contactId, bool affectedContactId})> { $$GroupHistoriesTableTableManager(_$TwonlyDB db, $GroupHistoriesTable table) : super(TableManagerState( db: db, @@ -13493,6 +13538,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager< updateCompanionCallback: ({ Value groupHistoryId = const Value.absent(), Value groupId = const Value.absent(), + Value contactId = const Value.absent(), Value affectedContactId = const Value.absent(), Value oldGroupName = const Value.absent(), Value newGroupName = const Value.absent(), @@ -13503,6 +13549,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager< GroupHistoriesCompanion( groupHistoryId: groupHistoryId, groupId: groupId, + contactId: contactId, affectedContactId: affectedContactId, oldGroupName: oldGroupName, newGroupName: newGroupName, @@ -13513,6 +13560,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager< createCompanionCallback: ({ required String groupHistoryId, required String groupId, + Value contactId = const Value.absent(), Value affectedContactId = const Value.absent(), Value oldGroupName = const Value.absent(), Value newGroupName = const Value.absent(), @@ -13523,6 +13571,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager< GroupHistoriesCompanion.insert( groupHistoryId: groupHistoryId, groupId: groupId, + contactId: contactId, affectedContactId: affectedContactId, oldGroupName: oldGroupName, newGroupName: newGroupName, @@ -13537,7 +13586,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager< )) .toList(), prefetchHooksCallback: ( - {groupId = false, affectedContactId = false}) { + {groupId = false, contactId = false, affectedContactId = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], @@ -13565,6 +13614,17 @@ class $$GroupHistoriesTableTableManager extends RootTableManager< .groupId, ) as T; } + if (contactId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.contactId, + referencedTable: + $$GroupHistoriesTableReferences._contactIdTable(db), + referencedColumn: $$GroupHistoriesTableReferences + ._contactIdTable(db) + .userId, + ) as T; + } if (affectedContactId) { state = state.withJoin( currentTable: table, @@ -13598,7 +13658,8 @@ typedef $$GroupHistoriesTableProcessedTableManager = ProcessedTableManager< $$GroupHistoriesTableUpdateCompanionBuilder, (GroupHistory, $$GroupHistoriesTableReferences), GroupHistory, - PrefetchHooks Function({bool groupId, bool affectedContactId})>; + PrefetchHooks Function( + {bool groupId, bool contactId, bool affectedContactId})>; class $TwonlyDBManager { final _$TwonlyDB _db; diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index 3c84c47..5a3c614 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -365,5 +365,12 @@ "selectGroupName": "Gruppennamen wählen", "groupNameInput": "Gruppennamen", "groupMembers": "Mitglieder", - "createGroup": "Gruppe erstellen" + "createGroup": "Gruppe erstellen", + "addMember": "Mitglied hinzufügen", + "leaveGroup": "Gruppe verlassen", + "createContactRequest": "Kontaktanfrage erstellen", + "makeAdmin": "Zum Admin machen", + "removeAdmin": "Als Admin entfernen", + "removeFromGroup": "Aus Gruppe entfernen", + "admin": "Admin" } \ No newline at end of file diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index 489cb1c..eb1cc6c 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -521,5 +521,12 @@ "selectGroupName": "Select group name", "groupNameInput": "Group name", "groupMembers": "Members", - "createGroup": "Create group" + "addMember": "Add member", + "createGroup": "Create group", + "leaveGroup": "Leave group", + "createContactRequest": "Create contact request", + "makeAdmin": "Make admin", + "removeAdmin": "Remove as admin", + "removeFromGroup": "Remove from group", + "admin": "Admin" } \ No newline at end of file diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 3466610..cda4669 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -2228,11 +2228,53 @@ abstract class AppLocalizations { /// **'Members'** String get groupMembers; + /// No description provided for @addMember. + /// + /// In en, this message translates to: + /// **'Add member'** + String get addMember; + /// No description provided for @createGroup. /// /// In en, this message translates to: /// **'Create group'** String get createGroup; + + /// No description provided for @leaveGroup. + /// + /// In en, this message translates to: + /// **'Leave group'** + String get leaveGroup; + + /// No description provided for @createContactRequest. + /// + /// In en, this message translates to: + /// **'Create contact request'** + String get createContactRequest; + + /// No description provided for @makeAdmin. + /// + /// In en, this message translates to: + /// **'Make admin'** + String get makeAdmin; + + /// No description provided for @removeAdmin. + /// + /// In en, this message translates to: + /// **'Remove as admin'** + String get removeAdmin; + + /// No description provided for @removeFromGroup. + /// + /// In en, this message translates to: + /// **'Remove from group'** + String get removeFromGroup; + + /// No description provided for @admin. + /// + /// In en, this message translates to: + /// **'Admin'** + String get admin; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 3d16a0d..f06b19a 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1181,6 +1181,27 @@ class AppLocalizationsDe extends AppLocalizations { @override String get groupMembers => 'Mitglieder'; + @override + String get addMember => 'Mitglied hinzufügen'; + @override String get createGroup => 'Gruppe erstellen'; + + @override + String get leaveGroup => 'Gruppe verlassen'; + + @override + String get createContactRequest => 'Kontaktanfrage erstellen'; + + @override + String get makeAdmin => 'Zum Admin machen'; + + @override + String get removeAdmin => 'Als Admin entfernen'; + + @override + String get removeFromGroup => 'Aus Gruppe entfernen'; + + @override + String get admin => 'Admin'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 1c61b1f..7ab19e8 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1174,6 +1174,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get groupMembers => 'Members'; + @override + String get addMember => 'Add member'; + @override String get createGroup => 'Create group'; + + @override + String get leaveGroup => 'Leave group'; + + @override + String get createContactRequest => 'Create contact request'; + + @override + String get makeAdmin => 'Make admin'; + + @override + String get removeAdmin => 'Remove as admin'; + + @override + String get removeFromGroup => 'Remove from group'; + + @override + String get admin => 'Admin'; } diff --git a/lib/src/services/api/client2client/groups.c2c.dart b/lib/src/services/api/client2client/groups.c2c.dart index 6f81859..5e18672 100644 --- a/lib/src/services/api/client2client/groups.c2c.dart +++ b/lib/src/services/api/client2client/groups.c2c.dart @@ -1,5 +1,4 @@ import 'dart:async'; - import 'package:drift/drift.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:twonly/globals.dart'; @@ -29,7 +28,7 @@ Future handleGroupCreate( groupId: Value(groupId), stateVersionId: const Value(0), stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)), - myGroupPrivateKey: Value(myGroupKey.getPrivateKey().serialize()), + myGroupPrivateKey: Value(myGroupKey.serialize()), groupName: const Value(''), joinedGroup: const Value(false), ), @@ -81,7 +80,53 @@ Future handleGroupUpdate( int fromUserId, String groupId, EncryptedContent_GroupUpdate update, -) async {} +) async { + Log.info('Got group update for $groupId from $fromUserId'); + + final actionType = groupActionTypeFromString(update.groupActionType); + if (actionType == null) { + Log.error('Group action ${update.groupActionType} is unknown ignoring.'); + return; + } + + final group = (await twonlyDB.groupsDao.getGroup(groupId))!; + + switch (actionType) { + case GroupActionType.updatedGroupName: + await twonlyDB.groupsDao.insertGroupAction( + GroupHistoriesCompanion( + groupId: Value(groupId), + type: Value(actionType), + oldGroupName: Value(group.groupName), + newGroupName: Value(update.newGroupName), + contactId: Value(fromUserId), + ), + ); + case GroupActionType.removedMember: + case GroupActionType.addMember: + case GroupActionType.leftGroup: + case GroupActionType.promoteToAdmin: + case GroupActionType.demoteToMember: + int? affectedContactId = update.affectedContactId.toInt(); + + if (affectedContactId == gUser.userId) { + affectedContactId = null; + } + + await twonlyDB.groupsDao.insertGroupAction( + GroupHistoriesCompanion( + groupId: Value(groupId), + type: Value(actionType), + affectedContactId: Value(affectedContactId), + contactId: Value(fromUserId), + ), + ); + case GroupActionType.createdGroup: + break; + } + + unawaited(fetchGroupState(group)); +} Future handleGroupJoin( int fromUserId, diff --git a/lib/src/services/group.services.dart b/lib/src/services/group.services.dart index 4168dbe..5c76bbc 100644 --- a/lib/src/services/group.services.dart +++ b/lib/src/services/group.services.dart @@ -1,5 +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'; @@ -8,6 +9,8 @@ import 'package:fixnum/fixnum.dart'; import 'package:hashlib/random.dart'; import 'package:http/http.dart' as http; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; +// ignore: implementation_imports +import 'package:libsignal_protocol_dart/src/ecc/ed25519.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; @@ -23,6 +26,10 @@ String getGroupStateUrl() { return 'http${apiService.apiSecure}://${apiService.apiHost}/api/group/state'; } +String getGroupChallengeUrl() { + return 'http${apiService.apiSecure}://${apiService.apiHost}/api/group/challenge'; +} + Future createNewGroup(String groupName, List members) async { // First: Upload new State to the server..... // if (groupName) return; @@ -90,7 +97,7 @@ Future createNewGroup(String groupName, List members) async { isGroupAdmin: const Value(true), stateEncryptionKey: Value(stateEncryptionKey), stateVersionId: const Value(1), - myGroupPrivateKey: Value(myGroupKey.getPrivateKey().serialize()), + myGroupPrivateKey: Value(myGroupKey.serialize()), joinedGroup: const Value(true), ), ); @@ -142,7 +149,7 @@ Future fetchGroupStatesForUnjoinedGroups() async { } } -Future fetchGroupState(Group group) async { +Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async { try { var isSuccess = true; @@ -156,7 +163,7 @@ Future fetchGroupState(Group group) async { Log.error( 'Could not load group state. Got status code ${response.statusCode} from server.', ); - return false; + return null; } final groupStateServer = GroupState.fromBuffer(response.bodyBytes); @@ -180,8 +187,10 @@ Future fetchGroupState(Group group) async { EncryptedGroupState.fromBuffer(encryptedGroupStateRaw); if (group.stateVersionId >= groupStateServer.versionId.toInt()) { - Log.error('Group ${group.groupId} has newest group state'); - return false; + Log.info( + 'Group ${group.groupId} has already newest group state from the server!', + ); + return (groupStateServer.versionId.toInt(), encryptedGroupState); } final isGroupAdmin = encryptedGroupState.adminIds @@ -204,6 +213,9 @@ Future fetchGroupState(Group group) async { // First find and insert NEW members for (final memberId in encryptedGroupState.memberIds) { + if (memberId == Int64(gUser.userId)) { + continue; + } if (currentGroupMembers.any((t) => t.contactId == memberId.toInt())) { // User is already in the database continue; @@ -280,10 +292,10 @@ Future fetchGroupState(Group group) async { ), ); } - return true; + return (groupStateServer.versionId.toInt(), encryptedGroupState); } catch (e) { Log.error(e); - return false; + return null; } } @@ -304,3 +316,112 @@ Future addNewHiddenContact(int contactId) async { await createNewSignalSession(userData); return true; } + +Future updateGroupState(Group group, EncryptedGroupState state) async { + final chacha20 = FlutterChacha20.poly1305Aead(); + final encryptionNonce = chacha20.newNonce(); + + final secretBox = await chacha20.encrypt( + state.writeToBuffer(), + secretKey: SecretKey(group.stateEncryptionKey!), + nonce: encryptionNonce, + ); + + final encryptedGroupState = EncryptedGroupStateEnvelop( + nonce: encryptionNonce, + encryptedGroupState: secretBox.cipherText, + mac: secretBox.mac.bytes, + ); + + { + // Upload the group state, if this fails, the group can not be created. + + final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!); + + final publicKey = uint8ListToHex(keyPair.getPublicKey().serialize()); + + final responseNonce = await http + .get( + Uri.parse('${getGroupChallengeUrl()}/$publicKey'), + ) + .timeout(const Duration(seconds: 10)); + + if (responseNonce.statusCode != 200) { + Log.error( + 'Could not load nonce. Got status code ${responseNonce.statusCode} from server.', + ); + return false; + } + + final updateTBS = UpdateGroupState_UpdateTBS( + versionId: Int64(group.stateVersionId + 1), + encryptedGroupState: encryptedGroupState.writeToBuffer(), + publicKey: keyPair.getPublicKey().serialize(), + nonce: responseNonce.bodyBytes, + ); + + final random = getRandomUint8List(32); + final signature = sign( + keyPair.getPrivateKey().serialize(), + updateTBS.writeToBuffer(), + random, + ); + + final newGroupState = UpdateGroupState( + update: updateTBS, + signature: signature, + ); + + final response = await http + .patch( + Uri.parse(getGroupStateUrl()), + body: newGroupState.writeToBuffer(), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + Log.error( + 'Could not patch group state. Got status code ${response.statusCode} from server.', + ); + return false; + } + } + + // Update database to the newest state + return (await fetchGroupState(group)) != null; +} + +Future updateGroupeName(Group group, String groupName) async { + // ensure the latest state is used + final currentState = await fetchGroupState(group); + if (currentState == null) return false; + final (versionId, state) = currentState; + + state.groupName = groupName; + + // send new state to the server + if (!await updateGroupState(group, state)) { + return false; + } + + await sendCipherTextToGroup( + group.groupId, + EncryptedContent( + groupUpdate: EncryptedContent_GroupUpdate( + groupActionType: GroupActionType.updatedGroupName.name, + newGroupName: groupName, + ), + ), + ); + + await twonlyDB.groupsDao.insertGroupAction( + GroupHistoriesCompanion( + groupId: Value(group.groupId), + type: const Value(GroupActionType.updatedGroupName), + oldGroupName: Value(group.groupName), + newGroupName: Value(groupName), + ), + ); + + return true; +} diff --git a/lib/src/views/chats/chat_list_components/group_list_item.dart b/lib/src/views/chats/chat_list_components/group_list_item.dart index 729ba02..c903069 100644 --- a/lib/src/views/chats/chat_list_components/group_list_item.dart +++ b/lib/src/views/chats/chat_list_components/group_list_item.dart @@ -17,6 +17,8 @@ import 'package:twonly/src/views/chats/media_viewer.view.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/group_context_menu.component.dart'; +import 'package:twonly/src/views/contact/contact.view.dart'; +import 'package:twonly/src/views/groups/group.view.dart'; class GroupListItem extends StatefulWidget { const GroupListItem({ @@ -232,7 +234,27 @@ class _UserListItem extends State { ), ], ), - leading: AvatarIcon(group: widget.group), + leading: GestureDetector( + onTap: () async { + Widget pushWidget = GroupView(widget.group); + + if (widget.group.isDirectChat) { + final contacts = await twonlyDB.groupsDao + .getGroupContact(widget.group.groupId); + pushWidget = ContactView(contacts.first.userId); + } + if (!context.mounted) return; + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return pushWidget; + }, + ), + ); + }, + child: AvatarIcon(group: widget.group), + ), trailing: IconButton( onPressed: () { Navigator.push( diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index f753800..94bc7fc 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -14,6 +14,7 @@ import 'package:twonly/src/services/notifications/background.notifications.dart' import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/camera/camera_send_to_view.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_date_chip.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/chat_group_action.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart'; import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart'; @@ -30,7 +31,12 @@ Color getMessageColor(Message message) { } class ChatItem { - const ChatItem._({this.message, this.date, this.lastOpenedPosition}); + const ChatItem._({ + this.message, + this.date, + this.lastOpenedPosition, + this.groupAction, + }); factory ChatItem.date(DateTime date) { return ChatItem._(date: date); } @@ -40,11 +46,16 @@ class ChatItem { factory ChatItem.lastOpenedPosition(List contacts) { return ChatItem._(lastOpenedPosition: contacts); } + factory ChatItem.groupAction(GroupHistory groupAction) { + return ChatItem._(groupAction: groupAction); + } + final GroupHistory? groupAction; final Message? message; final DateTime? date; final List? lastOpenedPosition; bool get isMessage => message != null; bool get isDate => date != null; + bool get isGroupAction => groupAction != null; bool get isLastOpenedPosition => lastOpenedPosition != null; } @@ -65,12 +76,14 @@ class _ChatMessagesViewState extends State { String currentInputText = ''; late StreamSubscription userSub; late StreamSubscription> messageSub; + late StreamSubscription>? groupActionsSub; late StreamSubscription>>? lastOpenedMessageByContactSub; List messages = []; List allMessages = []; List<(Message, Contact)> lastOpenedMessageByContact = []; + List groupActions = []; List galleryItems = []; Message? quotesMessage; GlobalKey verifyShieldKey = GlobalKey(); @@ -97,6 +110,7 @@ class _ChatMessagesViewState extends State { void dispose() { userSub.cancel(); messageSub.cancel(); + groupActionsSub?.cancel(); lastOpenedMessageByContactSub?.cancel(); tutorial?.cancel(); textFieldFocus.dispose(); @@ -121,7 +135,13 @@ class _ChatMessagesViewState extends State { lastOpenedStream.listen((lastActionsFuture) async { final update = await lastActionsFuture; lastOpenedMessageByContact = update; - await setMessages(allMessages, update); + await setMessages(allMessages, update, groupActions); + }); + + final actionsStream = twonlyDB.groupsDao.watchGroupActions(group.groupId); + groupActionsSub = actionsStream.listen((update) async { + groupActions = update; + await setMessages(allMessages, lastOpenedMessageByContact, update); }); } @@ -135,7 +155,7 @@ class _ChatMessagesViewState extends State { // return; } await protectMessageUpdating.protect(() async { - await setMessages(update, lastOpenedMessageByContact); + await setMessages(update, lastOpenedMessageByContact, groupActions); }); }); } @@ -143,6 +163,7 @@ class _ChatMessagesViewState extends State { Future setMessages( List newMessages, List<(Message, Contact)> lastOpenedMessageByContact, + List groupActions, ) async { await flutterLocalNotificationsPlugin.cancelAll(); @@ -165,8 +186,20 @@ class _ChatMessagesViewState extends State { } } var index = 0; + var groupHistoryIndex = 0; for (final msg in newMessages) { + if (groupHistoryIndex < groupActions.length) { + for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) { + if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) { + chatItems + .add(ChatItem.groupAction(groupActions[groupHistoryIndex])); + // groupHistoryIndex++; + } else { + break; + } + } + } index += 1; if (msg.type == MessageType.text && msg.senderId != null && @@ -200,6 +233,11 @@ class _ChatMessagesViewState extends State { } } } + if (groupHistoryIndex < groupActions.length) { + for (var i = groupHistoryIndex; i < groupActions.length; i++) { + chatItems.add(ChatItem.groupAction(groupActions[i])); + } + } for (final contactId in openedMessages.keys) { await notifyContactAboutOpeningMessage( @@ -262,9 +300,9 @@ class _ChatMessagesViewState extends State { appBar: AppBar( title: GestureDetector( onTap: () async { - if (widget.group.isDirectChat) { - final member = await twonlyDB.groupsDao - .getGroupMembers(widget.group.groupId); + if (group.isDirectChat) { + final member = + await twonlyDB.groupsDao.getGroupMembers(group.groupId); if (!context.mounted) return; await Navigator.push( context, @@ -279,7 +317,7 @@ class _ChatMessagesViewState extends State { context, MaterialPageRoute( builder: (context) { - return GroupView(widget.group); + return GroupView(group); }, ), ); @@ -298,7 +336,7 @@ class _ChatMessagesViewState extends State { child: Row( children: [ Text( - substringBy(widget.group.groupName, 20), + substringBy(group.groupName, 20), ), const SizedBox(width: 10), VerifiedShield(key: verifyShieldKey, group: group), @@ -342,6 +380,8 @@ class _ChatMessagesViewState extends State { ); }).toList(), ); + } else if (messages[i].isGroupAction) { + return ChatGroupAction(action: messages[i].groupAction!); } else { final chatMessage = messages[i].message!; return Transform.translate( diff --git a/lib/src/views/chats/chat_messages_components/chat_group_action.dart b/lib/src/views/chats/chat_messages_components/chat_group_action.dart new file mode 100644 index 0000000..0f7fa9d --- /dev/null +++ b/lib/src/views/chats/chat_messages_components/chat_group_action.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/tables/groups.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; + +class ChatGroupAction extends StatefulWidget { + const ChatGroupAction({ + required this.action, + super.key, + }); + + final GroupHistory action; + + @override + State createState() => _ChatGroupActionState(); +} + +class _ChatGroupActionState extends State { + Contact? contact; + Contact? affectedContact; + + @override + void initState() { + initAsync(); + super.initState(); + } + + Future initAsync() async { + if (widget.action.contactId == null) return; + contact = + await twonlyDB.contactsDao.getContactById(widget.action.contactId!); + + if (widget.action.affectedContactId == null) return; + affectedContact = await twonlyDB.contactsDao + .getContactById(widget.action.affectedContactId!); + + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + var text = ''; + + if (widget.action.type == GroupActionType.updatedGroupName) { + if (contact == null) { + text = + 'You have changed the group name to "${widget.action.newGroupName}".'; + } else { + text = + '${getContactDisplayName(contact!)} has changed the group name to "${widget.action.newGroupName}".'; + } + } + + if (text == '') return Container(); + + return Padding( + padding: const EdgeInsets.all(8), + child: Center( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: FaIcon( + FontAwesomeIcons.pencil, + size: 10, + color: Colors.grey, + ), + ), + const WidgetSpan(child: SizedBox(width: 8)), + TextSpan( + text: text, + style: const TextStyle(color: Colors.grey), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/views/components/avatar_icon.component.dart b/lib/src/views/components/avatar_icon.component.dart index 944cdba..2bab8f5 100644 --- a/lib/src/views/components/avatar_icon.component.dart +++ b/lib/src/views/components/avatar_icon.component.dart @@ -126,6 +126,7 @@ class _AvatarIconState extends State { } return Container( + key: GlobalKey(), constraints: BoxConstraints( minHeight: 2 * (widget.fontSize ?? 20), minWidth: 2 * (widget.fontSize ?? 20), diff --git a/lib/src/views/components/better_list_title.dart b/lib/src/views/components/better_list_title.dart index c83eca2..50fb542 100644 --- a/lib/src/views/components/better_list_title.dart +++ b/lib/src/views/components/better_list_title.dart @@ -3,16 +3,20 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class BetterListTile extends StatelessWidget { const BetterListTile({ - required this.icon, required this.text, required this.onTap, + this.icon, + this.leading, super.key, this.color, this.subtitle, + this.trailing, this.iconSize = 20, this.padding, }); - final IconData icon; + final IconData? icon; + final Widget? leading; + final Widget? trailing; final String text; final Widget? subtitle; final Color? color; @@ -30,12 +34,15 @@ class BetterListTile extends StatelessWidget { left: 19, ) : padding!, - child: FaIcon( - icon, - size: iconSize, - color: color, - ), + child: (leading != null) + ? leading + : FaIcon( + icon, + size: iconSize, + color: color, + ), ), + trailing: trailing, title: Text( text, style: TextStyle(color: color), diff --git a/lib/src/views/components/context_menu.component.dart b/lib/src/views/components/context_menu.component.dart index c2bd12e..5e6fd62 100644 --- a/lib/src/views/components/context_menu.component.dart +++ b/lib/src/views/components/context_menu.component.dart @@ -47,7 +47,7 @@ class _ContextMenuState extends State { elevation: 1, clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), // corner radius + borderRadius: BorderRadius.circular(12), ), popUpAnimationStyle: const AnimationStyle( duration: Duration.zero, @@ -56,7 +56,7 @@ class _ContextMenuState extends State { items: >[ ...widget.items.map( (item) => PopupMenuItem( - padding: EdgeInsets.zero, + padding: const EdgeInsets.only(right: 4), child: ListTile( title: Text(item.title), onTap: () async { diff --git a/lib/src/views/groups/group.view.dart b/lib/src/views/groups/group.view.dart index 85aa283..57c47ec 100644 --- a/lib/src/views/groups/group.view.dart +++ b/lib/src/views/groups/group.view.dart @@ -1,5 +1,18 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/group.services.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/components/avatar_icon.component.dart'; +import 'package:twonly/src/views/components/better_list_title.dart'; +import 'package:twonly/src/views/components/verified_shield.dart'; +import 'package:twonly/src/views/contact/contact.view.dart'; +import 'package:twonly/src/views/groups/group_member.context.dart'; +import 'package:twonly/src/views/settings/profile/profile.view.dart'; class GroupView extends StatefulWidget { const GroupView(this.group, {super.key}); @@ -11,8 +24,211 @@ class GroupView extends StatefulWidget { } class _GroupViewState extends State { + late Group group; + + List<(Contact, GroupMember)> members = []; + + late StreamSubscription groupSub; + late StreamSubscription> membersSub; + + @override + void initState() { + group = widget.group; + initAsync(); + super.initState(); + } + + @override + void dispose() { + groupSub.cancel(); + membersSub.cancel(); + super.dispose(); + } + + Future initAsync() async { + final groupStream = twonlyDB.groupsDao.watchGroup(widget.group.groupId); + groupSub = groupStream.listen((update) { + if (update != null) { + setState(() { + group = update; + }); + } + }); + final membersStream = + twonlyDB.groupsDao.watchGroupMembers(widget.group.groupId); + membersSub = membersStream.listen((update) { + setState(() { + members = update; + members.sort( + (b, a) => a.$2.memberState!.index.compareTo(b.$2.memberState!.index), + ); + }); + }); + } + + Future _updateGroupName() async { + final newGroupName = await showGroupNameChangeDialog(context, group); + + if (context.mounted && + newGroupName != null && + newGroupName != '' && + newGroupName != group.groupName) { + if (!await updateGroupeName(group, newGroupName)) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Network issue. Try again later.'), + duration: Duration(seconds: 3), + ), + ); + } + } + } + } + @override Widget build(BuildContext context) { - return const Placeholder(); + return Scaffold( + appBar: AppBar( + title: const Text(''), + ), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.all(10), + child: AvatarIcon( + group: group, + fontSize: 30, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(right: 10), + child: VerifiedShield(key: GlobalKey(), group: group), + ), + Text( + substringBy(group.groupName, 25), + style: const TextStyle(fontSize: 20), + ), + ], + ), + const SizedBox(height: 50), + if (group.isGroupAdmin) + BetterListTile( + icon: FontAwesomeIcons.pencil, + text: context.lang.groupNameInput, + onTap: _updateGroupName, + ), + const Divider(), + ListTile( + title: Padding( + padding: const EdgeInsets.only(left: 17), + child: Text( + '${members.length + 1} ${context.lang.groupMembers}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (group.isGroupAdmin) + BetterListTile( + icon: FontAwesomeIcons.plus, + text: context.lang.addMember, + onTap: () => {}, + ), + BetterListTile( + padding: const EdgeInsets.only(left: 13), + leading: AvatarIcon( + userData: gUser, + fontSize: 16, + ), + text: context.lang.you, + trailing: (group.isGroupAdmin) ? Text(context.lang.admin) : null, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ProfileView(), + ), + ); + }, + ), + ...members.map((member) { + return GroupMemberContextMenu( + group: widget.group, + contact: member.$1, + member: member.$2, + child: BetterListTile( + padding: const EdgeInsets.only(left: 13), + leading: AvatarIcon( + contact: member.$1, + fontSize: 16, + ), + text: getContactDisplayName(member.$1, maxLength: 25), + trailing: (member.$2.memberState == MemberState.admin) + ? Text(context.lang.admin) + : null, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ContactView(member.$1.userId), + ), + ); + }, + ), + ); + }), + const SizedBox(height: 10), + const Divider(), + const SizedBox(height: 10), + BetterListTile( + icon: FontAwesomeIcons.rightFromBracket, + color: Colors.red, + text: context.lang.leaveGroup, + onTap: () => {}, + ), + ], + ), + ); } } + +Future showGroupNameChangeDialog( + BuildContext context, + Group group, +) { + final controller = TextEditingController(text: group.groupName); + + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(context.lang.groupNameInput), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration(hintText: context.lang.groupNameInput), + ), + actions: [ + TextButton( + child: Text(context.lang.cancel), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: Text(context.lang.ok), + onPressed: () { + Navigator.of(context).pop(controller.text); + }, + ), + ], + ); + }, + ); +} diff --git a/lib/src/views/groups/group_member.context.dart b/lib/src/views/groups/group_member.context.dart new file mode 100644 index 0000000..4807e2f --- /dev/null +++ b/lib/src/views/groups/group_member.context.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +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/utils/misc.dart'; +import 'package:twonly/src/views/chats/chat_messages.view.dart'; +import 'package:twonly/src/views/components/context_menu.component.dart'; + +class GroupMemberContextMenu extends StatelessWidget { + const GroupMemberContextMenu({ + required this.contact, + required this.member, + required this.child, + required this.group, + super.key, + }); + final Contact contact; + final GroupMember member; + final Group group; + final Widget child; + + @override + Widget build(BuildContext context) { + return ContextMenu( + items: [ + if (contact.accepted) + ContextMenuItem( + title: context.lang.contextMenuOpenChat, + onTap: () async { + final directChat = + await twonlyDB.groupsDao.getDirectChat(contact.userId); + if (directChat == null) { + // create + return; + } + if (!context.mounted) return; + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatMessagesView(directChat), + ), + ); + }, + icon: FontAwesomeIcons.message, + ), + if (!contact.accepted) + ContextMenuItem( + title: context.lang.createContactRequest, + onTap: () async { + // onResponseTriggered(); + }, + icon: FontAwesomeIcons.userPlus, + ), + if (group.isGroupAdmin && member.memberState == MemberState.normal) + ContextMenuItem( + title: context.lang.makeAdmin, + onTap: () async { + // onResponseTriggered(); + }, + icon: FontAwesomeIcons.key, + ), + if (group.isGroupAdmin && member.memberState == MemberState.admin) + ContextMenuItem( + title: context.lang.removeAdmin, + onTap: () async { + // onResponseTriggered(); + }, + icon: FontAwesomeIcons.key, + ), + if (group.isGroupAdmin) + ContextMenuItem( + title: context.lang.removeFromGroup, + onTap: () async { + // onResponseTriggered(); + }, + icon: FontAwesomeIcons.rightFromBracket, + ), + ], + child: child, + ); + } +}