change group name possible #227

This commit is contained in:
otsmr 2025-11-01 23:45:37 +01:00
parent 9eea69b3dd
commit 30086c2475
18 changed files with 923 additions and 115 deletions

View file

@ -60,6 +60,13 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
await into(groupHistories).insert(insertAction); await into(groupHistories).insert(insertAction);
} }
Stream<List<GroupHistory>> watchGroupActions(String groupId) {
return (select(groupHistories)
..where((t) => t.groupId.equals(groupId))
..orderBy([(t) => OrderingTerm.asc(t.actionAt)]))
.watch();
}
Future<void> updateMember( Future<void> updateMember(
String groupId, String groupId,
int contactId, int contactId,
@ -139,6 +146,19 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
return query.map((row) => row.readTable(contacts)).watch(); return query.map((row) => row.readTable(contacts)).watch();
} }
Stream<List<(Contact, GroupMember)>> 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<List<Group>> watchGroups() { Stream<List<Group>> watchGroups() {
return select(groups).watch(); return select(groups).watch();
} }

View file

@ -82,6 +82,9 @@ class GroupHistories extends Table {
TextColumn get groupId => TextColumn get groupId =>
text().references(Groups, #groupId, onDelete: KeyAction.cascade)(); text().references(Groups, #groupId, onDelete: KeyAction.cascade)();
IntColumn get contactId =>
integer().nullable().references(Contacts, #userId)();
IntColumn get affectedContactId => IntColumn get affectedContactId =>
integer().nullable().references(Contacts, #userId)(); integer().nullable().references(Contacts, #userId)();
@ -95,3 +98,10 @@ class GroupHistories extends Table {
@override @override
Set<Column> get primaryKey => {groupHistoryId}; Set<Column> get primaryKey => {groupHistoryId};
} }
GroupActionType? groupActionTypeFromString(String name) {
for (final v in GroupActionType.values) {
if (v.name == name) return v;
}
return null;
}

View file

@ -6880,6 +6880,15 @@ class $GroupHistoriesTable extends GroupHistories
requiredDuringInsert: true, requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways( defaultConstraints: GeneratedColumn.constraintIsAlways(
'REFERENCES "groups" (group_id) ON DELETE CASCADE')); 'REFERENCES "groups" (group_id) ON DELETE CASCADE'));
static const VerificationMeta _contactIdMeta =
const VerificationMeta('contactId');
@override
late final GeneratedColumn<int> contactId = GeneratedColumn<int>(
'contact_id', aliasedName, true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('REFERENCES contacts (user_id)'));
static const VerificationMeta _affectedContactIdMeta = static const VerificationMeta _affectedContactIdMeta =
const VerificationMeta('affectedContactId'); const VerificationMeta('affectedContactId');
@override @override
@ -6918,6 +6927,7 @@ class $GroupHistoriesTable extends GroupHistories
List<GeneratedColumn> get $columns => [ List<GeneratedColumn> get $columns => [
groupHistoryId, groupHistoryId,
groupId, groupId,
contactId,
affectedContactId, affectedContactId,
oldGroupName, oldGroupName,
newGroupName, newGroupName,
@ -6948,6 +6958,10 @@ class $GroupHistoriesTable extends GroupHistories
} else if (isInserting) { } else if (isInserting) {
context.missing(_groupIdMeta); context.missing(_groupIdMeta);
} }
if (data.containsKey('contact_id')) {
context.handle(_contactIdMeta,
contactId.isAcceptableOrUnknown(data['contact_id']!, _contactIdMeta));
}
if (data.containsKey('affected_contact_id')) { if (data.containsKey('affected_contact_id')) {
context.handle( context.handle(
_affectedContactIdMeta, _affectedContactIdMeta,
@ -6983,6 +6997,8 @@ class $GroupHistoriesTable extends GroupHistories
DriftSqlType.string, data['${effectivePrefix}group_history_id'])!, DriftSqlType.string, data['${effectivePrefix}group_history_id'])!,
groupId: attachedDatabase.typeMapping groupId: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}group_id'])!, .read(DriftSqlType.string, data['${effectivePrefix}group_id'])!,
contactId: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}contact_id']),
affectedContactId: attachedDatabase.typeMapping.read( affectedContactId: attachedDatabase.typeMapping.read(
DriftSqlType.int, data['${effectivePrefix}affected_contact_id']), DriftSqlType.int, data['${effectivePrefix}affected_contact_id']),
oldGroupName: attachedDatabase.typeMapping oldGroupName: attachedDatabase.typeMapping
@ -7009,6 +7025,7 @@ class $GroupHistoriesTable extends GroupHistories
class GroupHistory extends DataClass implements Insertable<GroupHistory> { class GroupHistory extends DataClass implements Insertable<GroupHistory> {
final String groupHistoryId; final String groupHistoryId;
final String groupId; final String groupId;
final int? contactId;
final int? affectedContactId; final int? affectedContactId;
final String? oldGroupName; final String? oldGroupName;
final String? newGroupName; final String? newGroupName;
@ -7017,6 +7034,7 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
const GroupHistory( const GroupHistory(
{required this.groupHistoryId, {required this.groupHistoryId,
required this.groupId, required this.groupId,
this.contactId,
this.affectedContactId, this.affectedContactId,
this.oldGroupName, this.oldGroupName,
this.newGroupName, this.newGroupName,
@ -7027,6 +7045,9 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
final map = <String, Expression>{}; final map = <String, Expression>{};
map['group_history_id'] = Variable<String>(groupHistoryId); map['group_history_id'] = Variable<String>(groupHistoryId);
map['group_id'] = Variable<String>(groupId); map['group_id'] = Variable<String>(groupId);
if (!nullToAbsent || contactId != null) {
map['contact_id'] = Variable<int>(contactId);
}
if (!nullToAbsent || affectedContactId != null) { if (!nullToAbsent || affectedContactId != null) {
map['affected_contact_id'] = Variable<int>(affectedContactId); map['affected_contact_id'] = Variable<int>(affectedContactId);
} }
@ -7048,6 +7069,9 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
return GroupHistoriesCompanion( return GroupHistoriesCompanion(
groupHistoryId: Value(groupHistoryId), groupHistoryId: Value(groupHistoryId),
groupId: Value(groupId), groupId: Value(groupId),
contactId: contactId == null && nullToAbsent
? const Value.absent()
: Value(contactId),
affectedContactId: affectedContactId == null && nullToAbsent affectedContactId: affectedContactId == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(affectedContactId), : Value(affectedContactId),
@ -7068,6 +7092,7 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
return GroupHistory( return GroupHistory(
groupHistoryId: serializer.fromJson<String>(json['groupHistoryId']), groupHistoryId: serializer.fromJson<String>(json['groupHistoryId']),
groupId: serializer.fromJson<String>(json['groupId']), groupId: serializer.fromJson<String>(json['groupId']),
contactId: serializer.fromJson<int?>(json['contactId']),
affectedContactId: serializer.fromJson<int?>(json['affectedContactId']), affectedContactId: serializer.fromJson<int?>(json['affectedContactId']),
oldGroupName: serializer.fromJson<String?>(json['oldGroupName']), oldGroupName: serializer.fromJson<String?>(json['oldGroupName']),
newGroupName: serializer.fromJson<String?>(json['newGroupName']), newGroupName: serializer.fromJson<String?>(json['newGroupName']),
@ -7082,6 +7107,7 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
return <String, dynamic>{ return <String, dynamic>{
'groupHistoryId': serializer.toJson<String>(groupHistoryId), 'groupHistoryId': serializer.toJson<String>(groupHistoryId),
'groupId': serializer.toJson<String>(groupId), 'groupId': serializer.toJson<String>(groupId),
'contactId': serializer.toJson<int?>(contactId),
'affectedContactId': serializer.toJson<int?>(affectedContactId), 'affectedContactId': serializer.toJson<int?>(affectedContactId),
'oldGroupName': serializer.toJson<String?>(oldGroupName), 'oldGroupName': serializer.toJson<String?>(oldGroupName),
'newGroupName': serializer.toJson<String?>(newGroupName), 'newGroupName': serializer.toJson<String?>(newGroupName),
@ -7094,6 +7120,7 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
GroupHistory copyWith( GroupHistory copyWith(
{String? groupHistoryId, {String? groupHistoryId,
String? groupId, String? groupId,
Value<int?> contactId = const Value.absent(),
Value<int?> affectedContactId = const Value.absent(), Value<int?> affectedContactId = const Value.absent(),
Value<String?> oldGroupName = const Value.absent(), Value<String?> oldGroupName = const Value.absent(),
Value<String?> newGroupName = const Value.absent(), Value<String?> newGroupName = const Value.absent(),
@ -7102,6 +7129,7 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
GroupHistory( GroupHistory(
groupHistoryId: groupHistoryId ?? this.groupHistoryId, groupHistoryId: groupHistoryId ?? this.groupHistoryId,
groupId: groupId ?? this.groupId, groupId: groupId ?? this.groupId,
contactId: contactId.present ? contactId.value : this.contactId,
affectedContactId: affectedContactId.present affectedContactId: affectedContactId.present
? affectedContactId.value ? affectedContactId.value
: this.affectedContactId, : this.affectedContactId,
@ -7118,6 +7146,7 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
? data.groupHistoryId.value ? data.groupHistoryId.value
: this.groupHistoryId, : this.groupHistoryId,
groupId: data.groupId.present ? data.groupId.value : this.groupId, groupId: data.groupId.present ? data.groupId.value : this.groupId,
contactId: data.contactId.present ? data.contactId.value : this.contactId,
affectedContactId: data.affectedContactId.present affectedContactId: data.affectedContactId.present
? data.affectedContactId.value ? data.affectedContactId.value
: this.affectedContactId, : this.affectedContactId,
@ -7137,6 +7166,7 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
return (StringBuffer('GroupHistory(') return (StringBuffer('GroupHistory(')
..write('groupHistoryId: $groupHistoryId, ') ..write('groupHistoryId: $groupHistoryId, ')
..write('groupId: $groupId, ') ..write('groupId: $groupId, ')
..write('contactId: $contactId, ')
..write('affectedContactId: $affectedContactId, ') ..write('affectedContactId: $affectedContactId, ')
..write('oldGroupName: $oldGroupName, ') ..write('oldGroupName: $oldGroupName, ')
..write('newGroupName: $newGroupName, ') ..write('newGroupName: $newGroupName, ')
@ -7147,14 +7177,15 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
} }
@override @override
int get hashCode => Object.hash(groupHistoryId, groupId, affectedContactId, int get hashCode => Object.hash(groupHistoryId, groupId, contactId,
oldGroupName, newGroupName, type, actionAt); affectedContactId, oldGroupName, newGroupName, type, actionAt);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
(other is GroupHistory && (other is GroupHistory &&
other.groupHistoryId == this.groupHistoryId && other.groupHistoryId == this.groupHistoryId &&
other.groupId == this.groupId && other.groupId == this.groupId &&
other.contactId == this.contactId &&
other.affectedContactId == this.affectedContactId && other.affectedContactId == this.affectedContactId &&
other.oldGroupName == this.oldGroupName && other.oldGroupName == this.oldGroupName &&
other.newGroupName == this.newGroupName && other.newGroupName == this.newGroupName &&
@ -7165,6 +7196,7 @@ class GroupHistory extends DataClass implements Insertable<GroupHistory> {
class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> { class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> {
final Value<String> groupHistoryId; final Value<String> groupHistoryId;
final Value<String> groupId; final Value<String> groupId;
final Value<int?> contactId;
final Value<int?> affectedContactId; final Value<int?> affectedContactId;
final Value<String?> oldGroupName; final Value<String?> oldGroupName;
final Value<String?> newGroupName; final Value<String?> newGroupName;
@ -7174,6 +7206,7 @@ class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> {
const GroupHistoriesCompanion({ const GroupHistoriesCompanion({
this.groupHistoryId = const Value.absent(), this.groupHistoryId = const Value.absent(),
this.groupId = const Value.absent(), this.groupId = const Value.absent(),
this.contactId = const Value.absent(),
this.affectedContactId = const Value.absent(), this.affectedContactId = const Value.absent(),
this.oldGroupName = const Value.absent(), this.oldGroupName = const Value.absent(),
this.newGroupName = const Value.absent(), this.newGroupName = const Value.absent(),
@ -7184,6 +7217,7 @@ class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> {
GroupHistoriesCompanion.insert({ GroupHistoriesCompanion.insert({
required String groupHistoryId, required String groupHistoryId,
required String groupId, required String groupId,
this.contactId = const Value.absent(),
this.affectedContactId = const Value.absent(), this.affectedContactId = const Value.absent(),
this.oldGroupName = const Value.absent(), this.oldGroupName = const Value.absent(),
this.newGroupName = const Value.absent(), this.newGroupName = const Value.absent(),
@ -7196,6 +7230,7 @@ class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> {
static Insertable<GroupHistory> custom({ static Insertable<GroupHistory> custom({
Expression<String>? groupHistoryId, Expression<String>? groupHistoryId,
Expression<String>? groupId, Expression<String>? groupId,
Expression<int>? contactId,
Expression<int>? affectedContactId, Expression<int>? affectedContactId,
Expression<String>? oldGroupName, Expression<String>? oldGroupName,
Expression<String>? newGroupName, Expression<String>? newGroupName,
@ -7206,6 +7241,7 @@ class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> {
return RawValuesInsertable({ return RawValuesInsertable({
if (groupHistoryId != null) 'group_history_id': groupHistoryId, if (groupHistoryId != null) 'group_history_id': groupHistoryId,
if (groupId != null) 'group_id': groupId, if (groupId != null) 'group_id': groupId,
if (contactId != null) 'contact_id': contactId,
if (affectedContactId != null) 'affected_contact_id': affectedContactId, if (affectedContactId != null) 'affected_contact_id': affectedContactId,
if (oldGroupName != null) 'old_group_name': oldGroupName, if (oldGroupName != null) 'old_group_name': oldGroupName,
if (newGroupName != null) 'new_group_name': newGroupName, if (newGroupName != null) 'new_group_name': newGroupName,
@ -7218,6 +7254,7 @@ class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> {
GroupHistoriesCompanion copyWith( GroupHistoriesCompanion copyWith(
{Value<String>? groupHistoryId, {Value<String>? groupHistoryId,
Value<String>? groupId, Value<String>? groupId,
Value<int?>? contactId,
Value<int?>? affectedContactId, Value<int?>? affectedContactId,
Value<String?>? oldGroupName, Value<String?>? oldGroupName,
Value<String?>? newGroupName, Value<String?>? newGroupName,
@ -7227,6 +7264,7 @@ class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> {
return GroupHistoriesCompanion( return GroupHistoriesCompanion(
groupHistoryId: groupHistoryId ?? this.groupHistoryId, groupHistoryId: groupHistoryId ?? this.groupHistoryId,
groupId: groupId ?? this.groupId, groupId: groupId ?? this.groupId,
contactId: contactId ?? this.contactId,
affectedContactId: affectedContactId ?? this.affectedContactId, affectedContactId: affectedContactId ?? this.affectedContactId,
oldGroupName: oldGroupName ?? this.oldGroupName, oldGroupName: oldGroupName ?? this.oldGroupName,
newGroupName: newGroupName ?? this.newGroupName, newGroupName: newGroupName ?? this.newGroupName,
@ -7245,6 +7283,9 @@ class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> {
if (groupId.present) { if (groupId.present) {
map['group_id'] = Variable<String>(groupId.value); map['group_id'] = Variable<String>(groupId.value);
} }
if (contactId.present) {
map['contact_id'] = Variable<int>(contactId.value);
}
if (affectedContactId.present) { if (affectedContactId.present) {
map['affected_contact_id'] = Variable<int>(affectedContactId.value); map['affected_contact_id'] = Variable<int>(affectedContactId.value);
} }
@ -7272,6 +7313,7 @@ class GroupHistoriesCompanion extends UpdateCompanion<GroupHistory> {
return (StringBuffer('GroupHistoriesCompanion(') return (StringBuffer('GroupHistoriesCompanion(')
..write('groupHistoryId: $groupHistoryId, ') ..write('groupHistoryId: $groupHistoryId, ')
..write('groupId: $groupId, ') ..write('groupId: $groupId, ')
..write('contactId: $contactId, ')
..write('affectedContactId: $affectedContactId, ') ..write('affectedContactId: $affectedContactId, ')
..write('oldGroupName: $oldGroupName, ') ..write('oldGroupName: $oldGroupName, ')
..write('newGroupName: $newGroupName, ') ..write('newGroupName: $newGroupName, ')
@ -7568,22 +7610,6 @@ final class $$ContactsTableReferences
return ProcessedTableManager( return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: cache)); manager.$state.copyWith(prefetchedData: cache));
} }
static MultiTypedResultKey<$GroupHistoriesTable, List<GroupHistory>>
_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<int>('user_id')!));
final cache = $_typedResult.readTableOrNull(_groupHistoriesRefsTable($_db));
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: cache));
}
} }
class $$ContactsTableFilterComposer class $$ContactsTableFilterComposer
@ -7766,27 +7792,6 @@ class $$ContactsTableFilterComposer
)); ));
return f(composer); return f(composer);
} }
Expression<bool> groupHistoriesRefs(
Expression<bool> 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 class $$ContactsTableOrderingComposer
@ -8020,27 +8025,6 @@ class $$ContactsTableAnnotationComposer
)); ));
return f(composer); return f(composer);
} }
Expression<T> groupHistoriesRefs<T extends Object>(
Expression<T> 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< class $$ContactsTableTableManager extends RootTableManager<
@ -8060,8 +8044,7 @@ class $$ContactsTableTableManager extends RootTableManager<
bool groupMembersRefs, bool groupMembersRefs,
bool receiptsRefs, bool receiptsRefs,
bool signalContactPreKeysRefs, bool signalContactPreKeysRefs,
bool signalContactSignedPreKeysRefs, bool signalContactSignedPreKeysRefs})> {
bool groupHistoriesRefs})> {
$$ContactsTableTableManager(_$TwonlyDB db, $ContactsTable table) $$ContactsTableTableManager(_$TwonlyDB db, $ContactsTable table)
: super(TableManagerState( : super(TableManagerState(
db: db, db: db,
@ -8142,8 +8125,7 @@ class $$ContactsTableTableManager extends RootTableManager<
groupMembersRefs = false, groupMembersRefs = false,
receiptsRefs = false, receiptsRefs = false,
signalContactPreKeysRefs = false, signalContactPreKeysRefs = false,
signalContactSignedPreKeysRefs = false, signalContactSignedPreKeysRefs = false}) {
groupHistoriesRefs = false}) {
return PrefetchHooks( return PrefetchHooks(
db: db, db: db,
explicitlyWatchedTables: [ explicitlyWatchedTables: [
@ -8153,8 +8135,7 @@ class $$ContactsTableTableManager extends RootTableManager<
if (receiptsRefs) db.receipts, if (receiptsRefs) db.receipts,
if (signalContactPreKeysRefs) db.signalContactPreKeys, if (signalContactPreKeysRefs) db.signalContactPreKeys,
if (signalContactSignedPreKeysRefs) if (signalContactSignedPreKeysRefs)
db.signalContactSignedPreKeys, db.signalContactSignedPreKeys
if (groupHistoriesRefs) db.groupHistories
], ],
addJoins: null, addJoins: null,
getPrefetchedDataCallback: (items) async { getPrefetchedDataCallback: (items) async {
@ -8234,19 +8215,6 @@ class $$ContactsTableTableManager extends RootTableManager<
referencedItemsForCurrentItem: referencedItemsForCurrentItem:
(item, referencedItems) => referencedItems (item, referencedItems) => referencedItems
.where((e) => e.contactId == item.userId), .where((e) => e.contactId == item.userId),
typedResults: items),
if (groupHistoriesRefs)
await $_getPrefetchedData<Contact, $ContactsTable,
GroupHistory>(
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) typedResults: items)
]; ];
}, },
@ -8272,8 +8240,7 @@ typedef $$ContactsTableProcessedTableManager = ProcessedTableManager<
bool groupMembersRefs, bool groupMembersRefs,
bool receiptsRefs, bool receiptsRefs,
bool signalContactPreKeysRefs, bool signalContactPreKeysRefs,
bool signalContactSignedPreKeysRefs, bool signalContactSignedPreKeysRefs})>;
bool groupHistoriesRefs})>;
typedef $$GroupsTableCreateCompanionBuilder = GroupsCompanion Function({ typedef $$GroupsTableCreateCompanionBuilder = GroupsCompanion Function({
required String groupId, required String groupId,
Value<bool> isGroupAdmin, Value<bool> isGroupAdmin,
@ -13213,6 +13180,7 @@ typedef $$GroupHistoriesTableCreateCompanionBuilder = GroupHistoriesCompanion
Function({ Function({
required String groupHistoryId, required String groupHistoryId,
required String groupId, required String groupId,
Value<int?> contactId,
Value<int?> affectedContactId, Value<int?> affectedContactId,
Value<String?> oldGroupName, Value<String?> oldGroupName,
Value<String?> newGroupName, Value<String?> newGroupName,
@ -13224,6 +13192,7 @@ typedef $$GroupHistoriesTableUpdateCompanionBuilder = GroupHistoriesCompanion
Function({ Function({
Value<String> groupHistoryId, Value<String> groupHistoryId,
Value<String> groupId, Value<String> groupId,
Value<int?> contactId,
Value<int?> affectedContactId, Value<int?> affectedContactId,
Value<String?> oldGroupName, Value<String?> oldGroupName,
Value<String?> newGroupName, Value<String?> newGroupName,
@ -13251,6 +13220,21 @@ final class $$GroupHistoriesTableReferences
manager.$state.copyWith(prefetchedData: [item])); 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<int>('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) => static $ContactsTable _affectedContactIdTable(_$TwonlyDB db) =>
db.contacts.createAlias($_aliasNameGenerator( db.contacts.createAlias($_aliasNameGenerator(
db.groupHistories.affectedContactId, db.contacts.userId)); db.groupHistories.affectedContactId, db.contacts.userId));
@ -13314,6 +13298,26 @@ class $$GroupHistoriesTableFilterComposer
return composer; 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 { $$ContactsTableFilterComposer get affectedContactId {
final $$ContactsTableFilterComposer composer = $composerBuilder( final $$ContactsTableFilterComposer composer = $composerBuilder(
composer: this, composer: this,
@ -13382,6 +13386,26 @@ class $$GroupHistoriesTableOrderingComposer
return composer; 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 { $$ContactsTableOrderingComposer get affectedContactId {
final $$ContactsTableOrderingComposer composer = $composerBuilder( final $$ContactsTableOrderingComposer composer = $composerBuilder(
composer: this, composer: this,
@ -13447,6 +13471,26 @@ class $$GroupHistoriesTableAnnotationComposer
return composer; 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 { $$ContactsTableAnnotationComposer get affectedContactId {
final $$ContactsTableAnnotationComposer composer = $composerBuilder( final $$ContactsTableAnnotationComposer composer = $composerBuilder(
composer: this, composer: this,
@ -13479,7 +13523,8 @@ class $$GroupHistoriesTableTableManager extends RootTableManager<
$$GroupHistoriesTableUpdateCompanionBuilder, $$GroupHistoriesTableUpdateCompanionBuilder,
(GroupHistory, $$GroupHistoriesTableReferences), (GroupHistory, $$GroupHistoriesTableReferences),
GroupHistory, GroupHistory,
PrefetchHooks Function({bool groupId, bool affectedContactId})> { PrefetchHooks Function(
{bool groupId, bool contactId, bool affectedContactId})> {
$$GroupHistoriesTableTableManager(_$TwonlyDB db, $GroupHistoriesTable table) $$GroupHistoriesTableTableManager(_$TwonlyDB db, $GroupHistoriesTable table)
: super(TableManagerState( : super(TableManagerState(
db: db, db: db,
@ -13493,6 +13538,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager<
updateCompanionCallback: ({ updateCompanionCallback: ({
Value<String> groupHistoryId = const Value.absent(), Value<String> groupHistoryId = const Value.absent(),
Value<String> groupId = const Value.absent(), Value<String> groupId = const Value.absent(),
Value<int?> contactId = const Value.absent(),
Value<int?> affectedContactId = const Value.absent(), Value<int?> affectedContactId = const Value.absent(),
Value<String?> oldGroupName = const Value.absent(), Value<String?> oldGroupName = const Value.absent(),
Value<String?> newGroupName = const Value.absent(), Value<String?> newGroupName = const Value.absent(),
@ -13503,6 +13549,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager<
GroupHistoriesCompanion( GroupHistoriesCompanion(
groupHistoryId: groupHistoryId, groupHistoryId: groupHistoryId,
groupId: groupId, groupId: groupId,
contactId: contactId,
affectedContactId: affectedContactId, affectedContactId: affectedContactId,
oldGroupName: oldGroupName, oldGroupName: oldGroupName,
newGroupName: newGroupName, newGroupName: newGroupName,
@ -13513,6 +13560,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager<
createCompanionCallback: ({ createCompanionCallback: ({
required String groupHistoryId, required String groupHistoryId,
required String groupId, required String groupId,
Value<int?> contactId = const Value.absent(),
Value<int?> affectedContactId = const Value.absent(), Value<int?> affectedContactId = const Value.absent(),
Value<String?> oldGroupName = const Value.absent(), Value<String?> oldGroupName = const Value.absent(),
Value<String?> newGroupName = const Value.absent(), Value<String?> newGroupName = const Value.absent(),
@ -13523,6 +13571,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager<
GroupHistoriesCompanion.insert( GroupHistoriesCompanion.insert(
groupHistoryId: groupHistoryId, groupHistoryId: groupHistoryId,
groupId: groupId, groupId: groupId,
contactId: contactId,
affectedContactId: affectedContactId, affectedContactId: affectedContactId,
oldGroupName: oldGroupName, oldGroupName: oldGroupName,
newGroupName: newGroupName, newGroupName: newGroupName,
@ -13537,7 +13586,7 @@ class $$GroupHistoriesTableTableManager extends RootTableManager<
)) ))
.toList(), .toList(),
prefetchHooksCallback: ( prefetchHooksCallback: (
{groupId = false, affectedContactId = false}) { {groupId = false, contactId = false, affectedContactId = false}) {
return PrefetchHooks( return PrefetchHooks(
db: db, db: db,
explicitlyWatchedTables: [], explicitlyWatchedTables: [],
@ -13565,6 +13614,17 @@ class $$GroupHistoriesTableTableManager extends RootTableManager<
.groupId, .groupId,
) as T; ) 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) { if (affectedContactId) {
state = state.withJoin( state = state.withJoin(
currentTable: table, currentTable: table,
@ -13598,7 +13658,8 @@ typedef $$GroupHistoriesTableProcessedTableManager = ProcessedTableManager<
$$GroupHistoriesTableUpdateCompanionBuilder, $$GroupHistoriesTableUpdateCompanionBuilder,
(GroupHistory, $$GroupHistoriesTableReferences), (GroupHistory, $$GroupHistoriesTableReferences),
GroupHistory, GroupHistory,
PrefetchHooks Function({bool groupId, bool affectedContactId})>; PrefetchHooks Function(
{bool groupId, bool contactId, bool affectedContactId})>;
class $TwonlyDBManager { class $TwonlyDBManager {
final _$TwonlyDB _db; final _$TwonlyDB _db;

View file

@ -365,5 +365,12 @@
"selectGroupName": "Gruppennamen wählen", "selectGroupName": "Gruppennamen wählen",
"groupNameInput": "Gruppennamen", "groupNameInput": "Gruppennamen",
"groupMembers": "Mitglieder", "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"
} }

View file

@ -521,5 +521,12 @@
"selectGroupName": "Select group name", "selectGroupName": "Select group name",
"groupNameInput": "Group name", "groupNameInput": "Group name",
"groupMembers": "Members", "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"
} }

View file

@ -2228,11 +2228,53 @@ abstract class AppLocalizations {
/// **'Members'** /// **'Members'**
String get groupMembers; String get groupMembers;
/// No description provided for @addMember.
///
/// In en, this message translates to:
/// **'Add member'**
String get addMember;
/// No description provided for @createGroup. /// No description provided for @createGroup.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Create group'** /// **'Create group'**
String get createGroup; 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 class _AppLocalizationsDelegate

View file

@ -1181,6 +1181,27 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get groupMembers => 'Mitglieder'; String get groupMembers => 'Mitglieder';
@override
String get addMember => 'Mitglied hinzufügen';
@override @override
String get createGroup => 'Gruppe erstellen'; 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';
} }

View file

@ -1174,6 +1174,27 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get groupMembers => 'Members'; String get groupMembers => 'Members';
@override
String get addMember => 'Add member';
@override @override
String get createGroup => 'Create group'; 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';
} }

View file

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
@ -29,7 +28,7 @@ Future<void> handleGroupCreate(
groupId: Value(groupId), groupId: Value(groupId),
stateVersionId: const Value(0), stateVersionId: const Value(0),
stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)), stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)),
myGroupPrivateKey: Value(myGroupKey.getPrivateKey().serialize()), myGroupPrivateKey: Value(myGroupKey.serialize()),
groupName: const Value(''), groupName: const Value(''),
joinedGroup: const Value(false), joinedGroup: const Value(false),
), ),
@ -81,7 +80,53 @@ Future<void> handleGroupUpdate(
int fromUserId, int fromUserId,
String groupId, String groupId,
EncryptedContent_GroupUpdate update, 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<bool> handleGroupJoin( Future<bool> handleGroupJoin(
int fromUserId, int fromUserId,

View file

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
import 'package:cryptography_plus/cryptography_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:hashlib/random.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; 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/globals.dart';
import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
@ -23,6 +26,10 @@ String getGroupStateUrl() {
return 'http${apiService.apiSecure}://${apiService.apiHost}/api/group/state'; return 'http${apiService.apiSecure}://${apiService.apiHost}/api/group/state';
} }
String getGroupChallengeUrl() {
return 'http${apiService.apiSecure}://${apiService.apiHost}/api/group/challenge';
}
Future<bool> createNewGroup(String groupName, List<Contact> members) async { Future<bool> createNewGroup(String groupName, List<Contact> members) async {
// First: Upload new State to the server..... // First: Upload new State to the server.....
// if (groupName) return; // if (groupName) return;
@ -90,7 +97,7 @@ Future<bool> createNewGroup(String groupName, List<Contact> members) async {
isGroupAdmin: const Value(true), isGroupAdmin: const Value(true),
stateEncryptionKey: Value(stateEncryptionKey), stateEncryptionKey: Value(stateEncryptionKey),
stateVersionId: const Value(1), stateVersionId: const Value(1),
myGroupPrivateKey: Value(myGroupKey.getPrivateKey().serialize()), myGroupPrivateKey: Value(myGroupKey.serialize()),
joinedGroup: const Value(true), joinedGroup: const Value(true),
), ),
); );
@ -142,7 +149,7 @@ Future<void> fetchGroupStatesForUnjoinedGroups() async {
} }
} }
Future<bool> fetchGroupState(Group group) async { Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async {
try { try {
var isSuccess = true; var isSuccess = true;
@ -156,7 +163,7 @@ Future<bool> fetchGroupState(Group group) async {
Log.error( Log.error(
'Could not load group state. Got status code ${response.statusCode} from server.', 'Could not load group state. Got status code ${response.statusCode} from server.',
); );
return false; return null;
} }
final groupStateServer = GroupState.fromBuffer(response.bodyBytes); final groupStateServer = GroupState.fromBuffer(response.bodyBytes);
@ -180,8 +187,10 @@ Future<bool> fetchGroupState(Group group) async {
EncryptedGroupState.fromBuffer(encryptedGroupStateRaw); EncryptedGroupState.fromBuffer(encryptedGroupStateRaw);
if (group.stateVersionId >= groupStateServer.versionId.toInt()) { if (group.stateVersionId >= groupStateServer.versionId.toInt()) {
Log.error('Group ${group.groupId} has newest group state'); Log.info(
return false; 'Group ${group.groupId} has already newest group state from the server!',
);
return (groupStateServer.versionId.toInt(), encryptedGroupState);
} }
final isGroupAdmin = encryptedGroupState.adminIds final isGroupAdmin = encryptedGroupState.adminIds
@ -204,6 +213,9 @@ Future<bool> fetchGroupState(Group group) async {
// First find and insert NEW members // First find and insert NEW members
for (final memberId in encryptedGroupState.memberIds) { for (final memberId in encryptedGroupState.memberIds) {
if (memberId == Int64(gUser.userId)) {
continue;
}
if (currentGroupMembers.any((t) => t.contactId == memberId.toInt())) { if (currentGroupMembers.any((t) => t.contactId == memberId.toInt())) {
// User is already in the database // User is already in the database
continue; continue;
@ -280,10 +292,10 @@ Future<bool> fetchGroupState(Group group) async {
), ),
); );
} }
return true; return (groupStateServer.versionId.toInt(), encryptedGroupState);
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
return false; return null;
} }
} }
@ -304,3 +316,112 @@ Future<bool> addNewHiddenContact(int contactId) async {
await createNewSignalSession(userData); await createNewSignalSession(userData);
return true; return true;
} }
Future<bool> 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<bool> 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;
}

View file

@ -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/avatar_icon.component.dart';
import 'package:twonly/src/views/components/flame.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/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 { class GroupListItem extends StatefulWidget {
const GroupListItem({ const GroupListItem({
@ -232,7 +234,27 @@ class _UserListItem extends State<GroupListItem> {
), ),
], ],
), ),
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( trailing: IconButton(
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(

View file

@ -14,6 +14,7 @@ import 'package:twonly/src/services/notifications/background.notifications.dart'
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_send_to_view.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_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/chat_list_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart'; import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart';
@ -30,7 +31,12 @@ Color getMessageColor(Message message) {
} }
class ChatItem { 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) { factory ChatItem.date(DateTime date) {
return ChatItem._(date: date); return ChatItem._(date: date);
} }
@ -40,11 +46,16 @@ class ChatItem {
factory ChatItem.lastOpenedPosition(List<Contact> contacts) { factory ChatItem.lastOpenedPosition(List<Contact> contacts) {
return ChatItem._(lastOpenedPosition: contacts); return ChatItem._(lastOpenedPosition: contacts);
} }
factory ChatItem.groupAction(GroupHistory groupAction) {
return ChatItem._(groupAction: groupAction);
}
final GroupHistory? groupAction;
final Message? message; final Message? message;
final DateTime? date; final DateTime? date;
final List<Contact>? lastOpenedPosition; final List<Contact>? lastOpenedPosition;
bool get isMessage => message != null; bool get isMessage => message != null;
bool get isDate => date != null; bool get isDate => date != null;
bool get isGroupAction => groupAction != null;
bool get isLastOpenedPosition => lastOpenedPosition != null; bool get isLastOpenedPosition => lastOpenedPosition != null;
} }
@ -65,12 +76,14 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
String currentInputText = ''; String currentInputText = '';
late StreamSubscription<Group?> userSub; late StreamSubscription<Group?> userSub;
late StreamSubscription<List<Message>> messageSub; late StreamSubscription<List<Message>> messageSub;
late StreamSubscription<List<GroupHistory>>? groupActionsSub;
late StreamSubscription<Future<List<(Message, Contact)>>>? late StreamSubscription<Future<List<(Message, Contact)>>>?
lastOpenedMessageByContactSub; lastOpenedMessageByContactSub;
List<ChatItem> messages = []; List<ChatItem> messages = [];
List<Message> allMessages = []; List<Message> allMessages = [];
List<(Message, Contact)> lastOpenedMessageByContact = []; List<(Message, Contact)> lastOpenedMessageByContact = [];
List<GroupHistory> groupActions = [];
List<MemoryItem> galleryItems = []; List<MemoryItem> galleryItems = [];
Message? quotesMessage; Message? quotesMessage;
GlobalKey verifyShieldKey = GlobalKey(); GlobalKey verifyShieldKey = GlobalKey();
@ -97,6 +110,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
void dispose() { void dispose() {
userSub.cancel(); userSub.cancel();
messageSub.cancel(); messageSub.cancel();
groupActionsSub?.cancel();
lastOpenedMessageByContactSub?.cancel(); lastOpenedMessageByContactSub?.cancel();
tutorial?.cancel(); tutorial?.cancel();
textFieldFocus.dispose(); textFieldFocus.dispose();
@ -121,7 +135,13 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
lastOpenedStream.listen((lastActionsFuture) async { lastOpenedStream.listen((lastActionsFuture) async {
final update = await lastActionsFuture; final update = await lastActionsFuture;
lastOpenedMessageByContact = update; 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<ChatMessagesView> {
// return; // return;
} }
await protectMessageUpdating.protect(() async { await protectMessageUpdating.protect(() async {
await setMessages(update, lastOpenedMessageByContact); await setMessages(update, lastOpenedMessageByContact, groupActions);
}); });
}); });
} }
@ -143,6 +163,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
Future<void> setMessages( Future<void> setMessages(
List<Message> newMessages, List<Message> newMessages,
List<(Message, Contact)> lastOpenedMessageByContact, List<(Message, Contact)> lastOpenedMessageByContact,
List<GroupHistory> groupActions,
) async { ) async {
await flutterLocalNotificationsPlugin.cancelAll(); await flutterLocalNotificationsPlugin.cancelAll();
@ -165,8 +186,20 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
} }
} }
var index = 0; var index = 0;
var groupHistoryIndex = 0;
for (final msg in newMessages) { 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; index += 1;
if (msg.type == MessageType.text && if (msg.type == MessageType.text &&
msg.senderId != null && msg.senderId != null &&
@ -200,6 +233,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
} }
} }
} }
if (groupHistoryIndex < groupActions.length) {
for (var i = groupHistoryIndex; i < groupActions.length; i++) {
chatItems.add(ChatItem.groupAction(groupActions[i]));
}
}
for (final contactId in openedMessages.keys) { for (final contactId in openedMessages.keys) {
await notifyContactAboutOpeningMessage( await notifyContactAboutOpeningMessage(
@ -262,9 +300,9 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
appBar: AppBar( appBar: AppBar(
title: GestureDetector( title: GestureDetector(
onTap: () async { onTap: () async {
if (widget.group.isDirectChat) { if (group.isDirectChat) {
final member = await twonlyDB.groupsDao final member =
.getGroupMembers(widget.group.groupId); await twonlyDB.groupsDao.getGroupMembers(group.groupId);
if (!context.mounted) return; if (!context.mounted) return;
await Navigator.push( await Navigator.push(
context, context,
@ -279,7 +317,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) { builder: (context) {
return GroupView(widget.group); return GroupView(group);
}, },
), ),
); );
@ -298,7 +336,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
child: Row( child: Row(
children: [ children: [
Text( Text(
substringBy(widget.group.groupName, 20), substringBy(group.groupName, 20),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
VerifiedShield(key: verifyShieldKey, group: group), VerifiedShield(key: verifyShieldKey, group: group),
@ -342,6 +380,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
); );
}).toList(), }).toList(),
); );
} else if (messages[i].isGroupAction) {
return ChatGroupAction(action: messages[i].groupAction!);
} else { } else {
final chatMessage = messages[i].message!; final chatMessage = messages[i].message!;
return Transform.translate( return Transform.translate(

View file

@ -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<ChatGroupAction> createState() => _ChatGroupActionState();
}
class _ChatGroupActionState extends State<ChatGroupAction> {
Contact? contact;
Contact? affectedContact;
@override
void initState() {
initAsync();
super.initState();
}
Future<void> 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),
),
],
),
),
),
);
}
}

View file

@ -126,6 +126,7 @@ class _AvatarIconState extends State<AvatarIcon> {
} }
return Container( return Container(
key: GlobalKey(),
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: 2 * (widget.fontSize ?? 20), minHeight: 2 * (widget.fontSize ?? 20),
minWidth: 2 * (widget.fontSize ?? 20), minWidth: 2 * (widget.fontSize ?? 20),

View file

@ -3,16 +3,20 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
class BetterListTile extends StatelessWidget { class BetterListTile extends StatelessWidget {
const BetterListTile({ const BetterListTile({
required this.icon,
required this.text, required this.text,
required this.onTap, required this.onTap,
this.icon,
this.leading,
super.key, super.key,
this.color, this.color,
this.subtitle, this.subtitle,
this.trailing,
this.iconSize = 20, this.iconSize = 20,
this.padding, this.padding,
}); });
final IconData icon; final IconData? icon;
final Widget? leading;
final Widget? trailing;
final String text; final String text;
final Widget? subtitle; final Widget? subtitle;
final Color? color; final Color? color;
@ -30,12 +34,15 @@ class BetterListTile extends StatelessWidget {
left: 19, left: 19,
) )
: padding!, : padding!,
child: FaIcon( child: (leading != null)
icon, ? leading
size: iconSize, : FaIcon(
color: color, icon,
), size: iconSize,
color: color,
),
), ),
trailing: trailing,
title: Text( title: Text(
text, text,
style: TextStyle(color: color), style: TextStyle(color: color),

View file

@ -47,7 +47,7 @@ class _ContextMenuState extends State<ContextMenu> {
elevation: 1, elevation: 1,
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // corner radius borderRadius: BorderRadius.circular(12),
), ),
popUpAnimationStyle: const AnimationStyle( popUpAnimationStyle: const AnimationStyle(
duration: Duration.zero, duration: Duration.zero,
@ -56,7 +56,7 @@ class _ContextMenuState extends State<ContextMenu> {
items: <PopupMenuEntry<int>>[ items: <PopupMenuEntry<int>>[
...widget.items.map( ...widget.items.map(
(item) => PopupMenuItem( (item) => PopupMenuItem(
padding: EdgeInsets.zero, padding: const EdgeInsets.only(right: 4),
child: ListTile( child: ListTile(
title: Text(item.title), title: Text(item.title),
onTap: () async { onTap: () async {

View file

@ -1,5 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart'; 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/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 { class GroupView extends StatefulWidget {
const GroupView(this.group, {super.key}); const GroupView(this.group, {super.key});
@ -11,8 +24,211 @@ class GroupView extends StatefulWidget {
} }
class _GroupViewState extends State<GroupView> { class _GroupViewState extends State<GroupView> {
late Group group;
List<(Contact, GroupMember)> members = [];
late StreamSubscription<Group?> groupSub;
late StreamSubscription<List<(Contact, GroupMember)>> membersSub;
@override
void initState() {
group = widget.group;
initAsync();
super.initState();
}
@override
void dispose() {
groupSub.cancel();
membersSub.cancel();
super.dispose();
}
Future<void> 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<void> _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 @override
Widget build(BuildContext context) { 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<String?> showGroupNameChangeDialog(
BuildContext context,
Group group,
) {
final controller = TextEditingController(text: group.groupName);
return showDialog<String>(
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: <Widget>[
TextButton(
child: Text(context.lang.cancel),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: Text(context.lang.ok),
onPressed: () {
Navigator.of(context).pop(controller.text);
},
),
],
);
},
);
}

View file

@ -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,
);
}
}