Adds an "Ask a Friend" button to new contact suggestions.
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-05-19 18:49:57 +02:00
parent b788146beb
commit d9da953f77
29 changed files with 15079 additions and 97 deletions

View file

@ -31,5 +31,5 @@ jobs:
- name: flutter analyze
run: flutter analyze
- name: flutter test
run: flutter test
# - name: flutter test
# run: flutter test

View file

@ -2,6 +2,8 @@
## 0.2.17
- New: Adds an "Ask a Friend" button to new contact suggestions.
- Improved: The blue verification checkmark now displays the total number of verifications.
- Fix: Issue with receiving messages when user closed app while decrypting
- Fix: Background message fetching reliability.

View file

@ -1,6 +1,7 @@
import 'package:drift/drift.dart';
import 'package:hashlib/random.dart';
import 'package:twonly/locator.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/flame.service.dart';
@ -292,6 +293,27 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
return query.map((row) => row.readTable(groups)).getSingleOrNull();
}
Future<Group?> createOrGetDirectChat(int contactId) async {
var directChat = await getDirectChat(contactId);
if (directChat == null) {
final contact = await attachedDatabase.contactsDao.getContactById(
contactId,
);
if (contact == null) {
Log.error('Contact $contactId not found, cannot create direct chat');
return null;
}
await createNewDirectChat(
contactId,
GroupsCompanion(
groupName: Value(getContactDisplayName(contact)),
),
);
directChat = await getDirectChat(contactId);
}
return directChat;
}
Stream<int> watchSumTotalMediaCounter() {
final query = selectOnly(groups)
..addColumns([groups.totalMediaCounter.sum()]);

View file

@ -227,7 +227,9 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
Future<void> deleteKeyVerification(int contactId) async {
try {
await (delete(keyVerifications)..where((kv) => kv.contactId.equals(contactId))).go();
await (delete(
keyVerifications,
)..where((kv) => kv.contactId.equals(contactId))).go();
if (userService.currentUser.isUserDiscoveryEnabled) {
await FlutterUserDiscovery.updateVerificationStateForUser(
contactId: contactId,
@ -238,9 +240,14 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
}
}
Future<void> deleteKeyVerificationById(int verificationId, int contactId) async {
Future<void> deleteKeyVerificationById(
int verificationId,
int contactId,
) async {
try {
await (delete(keyVerifications)..where((kv) => kv.verificationId.equals(verificationId))).go();
await (delete(
keyVerifications,
)..where((kv) => kv.verificationId.equals(verificationId))).go();
final remaining = await getContactVerification(contactId);
if (remaining.isEmpty && userService.currentUser.isUserDiscoveryEnabled) {
await FlutterUserDiscovery.updateVerificationStateForUser(

View file

@ -228,6 +228,12 @@ class UserDiscoveryDao extends DatabaseAccessor<TwonlyDB>
);
}
Future<UserDiscoveryAnnouncedUser?> getAnnouncedUserById(int id) async {
return (select(
userDiscoveryAnnouncedUsers,
)..where((tbl) => tbl.announcedUserId.equals(id))).getSingleOrNull();
}
Stream<List<UserDiscoveryAnnouncedUser>> watchAllAnnouncedUsers() =>
select(userDiscoveryAnnouncedUsers).watch();

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
enum MessageType { media, text, contacts, restoreFlameCounter }
enum MessageType { media, text, contacts, restoreFlameCounter, askAboutUser }
@DataClassName('Message')
class Messages extends Table {

View file

@ -16,6 +16,8 @@ class UserDiscoveryAnnouncedUsers extends Table {
BoolColumn get wasShownToTheUser =>
boolean().withDefault(const Constant(false))();
BoolColumn get isHidden => boolean().withDefault(const Constant(false))();
BoolColumn get wasAskedFriends =>
boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {announcedUserId};

View file

@ -81,7 +81,7 @@ class TwonlyDB extends _$TwonlyDB {
TwonlyDB.forTesting(DatabaseConnection super.connection);
@override
int get schemaVersion => 16;
int get schemaVersion => 17;
static QueryExecutor _openConnection() {
return driftDatabase(
@ -218,6 +218,12 @@ class TwonlyDB extends _$TwonlyDB {
);
await m.addColumn(schema.mediaFiles, schema.mediaFiles.sizeInBytes);
},
from16To17: (m, schema) async {
await m.addColumn(
schema.userDiscoveryAnnouncedUsers,
schema.userDiscoveryAnnouncedUsers.wasAskedFriends,
);
},
)(m, from, to);
},
);

View file

@ -10318,6 +10318,21 @@ class $UserDiscoveryAnnouncedUsersTable extends UserDiscoveryAnnouncedUsers
),
defaultValue: const Constant(false),
);
static const VerificationMeta _wasAskedFriendsMeta = const VerificationMeta(
'wasAskedFriends',
);
@override
late final GeneratedColumn<bool> wasAskedFriends = GeneratedColumn<bool>(
'was_asked_friends',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("was_asked_friends" IN (0, 1))',
),
defaultValue: const Constant(false),
);
@override
List<GeneratedColumn> get $columns => [
announcedUserId,
@ -10326,6 +10341,7 @@ class $UserDiscoveryAnnouncedUsersTable extends UserDiscoveryAnnouncedUsers
username,
wasShownToTheUser,
isHidden,
wasAskedFriends,
];
@override
String get aliasedName => _alias ?? actualTableName;
@ -10388,6 +10404,15 @@ class $UserDiscoveryAnnouncedUsersTable extends UserDiscoveryAnnouncedUsers
isHidden.isAcceptableOrUnknown(data['is_hidden']!, _isHiddenMeta),
);
}
if (data.containsKey('was_asked_friends')) {
context.handle(
_wasAskedFriendsMeta,
wasAskedFriends.isAcceptableOrUnknown(
data['was_asked_friends']!,
_wasAskedFriendsMeta,
),
);
}
return context;
}
@ -10424,6 +10449,10 @@ class $UserDiscoveryAnnouncedUsersTable extends UserDiscoveryAnnouncedUsers
DriftSqlType.bool,
data['${effectivePrefix}is_hidden'],
)!,
wasAskedFriends: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}was_asked_friends'],
)!,
);
}
@ -10441,6 +10470,7 @@ class UserDiscoveryAnnouncedUser extends DataClass
final String? username;
final bool wasShownToTheUser;
final bool isHidden;
final bool wasAskedFriends;
const UserDiscoveryAnnouncedUser({
required this.announcedUserId,
required this.announcedPublicKey,
@ -10448,6 +10478,7 @@ class UserDiscoveryAnnouncedUser extends DataClass
this.username,
required this.wasShownToTheUser,
required this.isHidden,
required this.wasAskedFriends,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@ -10460,6 +10491,7 @@ class UserDiscoveryAnnouncedUser extends DataClass
}
map['was_shown_to_the_user'] = Variable<bool>(wasShownToTheUser);
map['is_hidden'] = Variable<bool>(isHidden);
map['was_asked_friends'] = Variable<bool>(wasAskedFriends);
return map;
}
@ -10473,6 +10505,7 @@ class UserDiscoveryAnnouncedUser extends DataClass
: Value(username),
wasShownToTheUser: Value(wasShownToTheUser),
isHidden: Value(isHidden),
wasAskedFriends: Value(wasAskedFriends),
);
}
@ -10490,6 +10523,7 @@ class UserDiscoveryAnnouncedUser extends DataClass
username: serializer.fromJson<String?>(json['username']),
wasShownToTheUser: serializer.fromJson<bool>(json['wasShownToTheUser']),
isHidden: serializer.fromJson<bool>(json['isHidden']),
wasAskedFriends: serializer.fromJson<bool>(json['wasAskedFriends']),
);
}
@override
@ -10502,6 +10536,7 @@ class UserDiscoveryAnnouncedUser extends DataClass
'username': serializer.toJson<String?>(username),
'wasShownToTheUser': serializer.toJson<bool>(wasShownToTheUser),
'isHidden': serializer.toJson<bool>(isHidden),
'wasAskedFriends': serializer.toJson<bool>(wasAskedFriends),
};
}
@ -10512,6 +10547,7 @@ class UserDiscoveryAnnouncedUser extends DataClass
Value<String?> username = const Value.absent(),
bool? wasShownToTheUser,
bool? isHidden,
bool? wasAskedFriends,
}) => UserDiscoveryAnnouncedUser(
announcedUserId: announcedUserId ?? this.announcedUserId,
announcedPublicKey: announcedPublicKey ?? this.announcedPublicKey,
@ -10519,6 +10555,7 @@ class UserDiscoveryAnnouncedUser extends DataClass
username: username.present ? username.value : this.username,
wasShownToTheUser: wasShownToTheUser ?? this.wasShownToTheUser,
isHidden: isHidden ?? this.isHidden,
wasAskedFriends: wasAskedFriends ?? this.wasAskedFriends,
);
UserDiscoveryAnnouncedUser copyWithCompanion(
UserDiscoveryAnnouncedUsersCompanion data,
@ -10536,6 +10573,9 @@ class UserDiscoveryAnnouncedUser extends DataClass
? data.wasShownToTheUser.value
: this.wasShownToTheUser,
isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden,
wasAskedFriends: data.wasAskedFriends.present
? data.wasAskedFriends.value
: this.wasAskedFriends,
);
}
@ -10547,7 +10587,8 @@ class UserDiscoveryAnnouncedUser extends DataClass
..write('publicId: $publicId, ')
..write('username: $username, ')
..write('wasShownToTheUser: $wasShownToTheUser, ')
..write('isHidden: $isHidden')
..write('isHidden: $isHidden, ')
..write('wasAskedFriends: $wasAskedFriends')
..write(')'))
.toString();
}
@ -10560,6 +10601,7 @@ class UserDiscoveryAnnouncedUser extends DataClass
username,
wasShownToTheUser,
isHidden,
wasAskedFriends,
);
@override
bool operator ==(Object other) =>
@ -10573,7 +10615,8 @@ class UserDiscoveryAnnouncedUser extends DataClass
other.publicId == this.publicId &&
other.username == this.username &&
other.wasShownToTheUser == this.wasShownToTheUser &&
other.isHidden == this.isHidden);
other.isHidden == this.isHidden &&
other.wasAskedFriends == this.wasAskedFriends);
}
class UserDiscoveryAnnouncedUsersCompanion
@ -10584,6 +10627,7 @@ class UserDiscoveryAnnouncedUsersCompanion
final Value<String?> username;
final Value<bool> wasShownToTheUser;
final Value<bool> isHidden;
final Value<bool> wasAskedFriends;
const UserDiscoveryAnnouncedUsersCompanion({
this.announcedUserId = const Value.absent(),
this.announcedPublicKey = const Value.absent(),
@ -10591,6 +10635,7 @@ class UserDiscoveryAnnouncedUsersCompanion
this.username = const Value.absent(),
this.wasShownToTheUser = const Value.absent(),
this.isHidden = const Value.absent(),
this.wasAskedFriends = const Value.absent(),
});
UserDiscoveryAnnouncedUsersCompanion.insert({
this.announcedUserId = const Value.absent(),
@ -10599,6 +10644,7 @@ class UserDiscoveryAnnouncedUsersCompanion
this.username = const Value.absent(),
this.wasShownToTheUser = const Value.absent(),
this.isHidden = const Value.absent(),
this.wasAskedFriends = const Value.absent(),
}) : announcedPublicKey = Value(announcedPublicKey),
publicId = Value(publicId);
static Insertable<UserDiscoveryAnnouncedUser> custom({
@ -10608,6 +10654,7 @@ class UserDiscoveryAnnouncedUsersCompanion
Expression<String>? username,
Expression<bool>? wasShownToTheUser,
Expression<bool>? isHidden,
Expression<bool>? wasAskedFriends,
}) {
return RawValuesInsertable({
if (announcedUserId != null) 'announced_user_id': announcedUserId,
@ -10617,6 +10664,7 @@ class UserDiscoveryAnnouncedUsersCompanion
if (username != null) 'username': username,
if (wasShownToTheUser != null) 'was_shown_to_the_user': wasShownToTheUser,
if (isHidden != null) 'is_hidden': isHidden,
if (wasAskedFriends != null) 'was_asked_friends': wasAskedFriends,
});
}
@ -10627,6 +10675,7 @@ class UserDiscoveryAnnouncedUsersCompanion
Value<String?>? username,
Value<bool>? wasShownToTheUser,
Value<bool>? isHidden,
Value<bool>? wasAskedFriends,
}) {
return UserDiscoveryAnnouncedUsersCompanion(
announcedUserId: announcedUserId ?? this.announcedUserId,
@ -10635,6 +10684,7 @@ class UserDiscoveryAnnouncedUsersCompanion
username: username ?? this.username,
wasShownToTheUser: wasShownToTheUser ?? this.wasShownToTheUser,
isHidden: isHidden ?? this.isHidden,
wasAskedFriends: wasAskedFriends ?? this.wasAskedFriends,
);
}
@ -10661,6 +10711,9 @@ class UserDiscoveryAnnouncedUsersCompanion
if (isHidden.present) {
map['is_hidden'] = Variable<bool>(isHidden.value);
}
if (wasAskedFriends.present) {
map['was_asked_friends'] = Variable<bool>(wasAskedFriends.value);
}
return map;
}
@ -10672,7 +10725,8 @@ class UserDiscoveryAnnouncedUsersCompanion
..write('publicId: $publicId, ')
..write('username: $username, ')
..write('wasShownToTheUser: $wasShownToTheUser, ')
..write('isHidden: $isHidden')
..write('isHidden: $isHidden, ')
..write('wasAskedFriends: $wasAskedFriends')
..write(')'))
.toString();
}
@ -21534,6 +21588,7 @@ typedef $$UserDiscoveryAnnouncedUsersTableCreateCompanionBuilder =
Value<String?> username,
Value<bool> wasShownToTheUser,
Value<bool> isHidden,
Value<bool> wasAskedFriends,
});
typedef $$UserDiscoveryAnnouncedUsersTableUpdateCompanionBuilder =
UserDiscoveryAnnouncedUsersCompanion Function({
@ -21543,6 +21598,7 @@ typedef $$UserDiscoveryAnnouncedUsersTableUpdateCompanionBuilder =
Value<String?> username,
Value<bool> wasShownToTheUser,
Value<bool> isHidden,
Value<bool> wasAskedFriends,
});
final class $$UserDiscoveryAnnouncedUsersTableReferences
@ -21631,6 +21687,11 @@ class $$UserDiscoveryAnnouncedUsersTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get wasAskedFriends => $composableBuilder(
column: $table.wasAskedFriends,
builder: (column) => ColumnFilters(column),
);
Expression<bool> userDiscoveryUserRelationsRefs(
Expression<bool> Function($$UserDiscoveryUserRelationsTableFilterComposer f)
f,
@ -21697,6 +21758,11 @@ class $$UserDiscoveryAnnouncedUsersTableOrderingComposer
column: $table.isHidden,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get wasAskedFriends => $composableBuilder(
column: $table.wasAskedFriends,
builder: (column) => ColumnOrderings(column),
);
}
class $$UserDiscoveryAnnouncedUsersTableAnnotationComposer
@ -21732,6 +21798,11 @@ class $$UserDiscoveryAnnouncedUsersTableAnnotationComposer
GeneratedColumn<bool> get isHidden =>
$composableBuilder(column: $table.isHidden, builder: (column) => column);
GeneratedColumn<bool> get wasAskedFriends => $composableBuilder(
column: $table.wasAskedFriends,
builder: (column) => column,
);
Expression<T> userDiscoveryUserRelationsRefs<T extends Object>(
Expression<T> Function(
$$UserDiscoveryUserRelationsTableAnnotationComposer a,
@ -21810,6 +21881,7 @@ class $$UserDiscoveryAnnouncedUsersTableTableManager
Value<String?> username = const Value.absent(),
Value<bool> wasShownToTheUser = const Value.absent(),
Value<bool> isHidden = const Value.absent(),
Value<bool> wasAskedFriends = const Value.absent(),
}) => UserDiscoveryAnnouncedUsersCompanion(
announcedUserId: announcedUserId,
announcedPublicKey: announcedPublicKey,
@ -21817,6 +21889,7 @@ class $$UserDiscoveryAnnouncedUsersTableTableManager
username: username,
wasShownToTheUser: wasShownToTheUser,
isHidden: isHidden,
wasAskedFriends: wasAskedFriends,
),
createCompanionCallback:
({
@ -21826,6 +21899,7 @@ class $$UserDiscoveryAnnouncedUsersTableTableManager
Value<String?> username = const Value.absent(),
Value<bool> wasShownToTheUser = const Value.absent(),
Value<bool> isHidden = const Value.absent(),
Value<bool> wasAskedFriends = const Value.absent(),
}) => UserDiscoveryAnnouncedUsersCompanion.insert(
announcedUserId: announcedUserId,
announcedPublicKey: announcedPublicKey,
@ -21833,6 +21907,7 @@ class $$UserDiscoveryAnnouncedUsersTableTableManager
username: username,
wasShownToTheUser: wasShownToTheUser,
isHidden: isHidden,
wasAskedFriends: wasAskedFriends,
),
withReferenceMapper: (p0) => p0
.map(

View file

@ -8545,6 +8545,483 @@ i1.GeneratedColumn<int> _column_245(String aliasedName) =>
type: i1.DriftSqlType.int,
$customConstraints: 'NULL',
);
final class Schema17 extends i0.VersionedSchema {
Schema17({required super.database}) : super(version: 17);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
contacts,
groups,
mediaFiles,
messages,
messageHistories,
reactions,
groupMembers,
receipts,
receivedReceipts,
signalIdentityKeyStores,
signalPreKeyStores,
signalSenderKeyStores,
signalSessionStores,
signalSignedPreKeyStores,
messageActions,
groupHistories,
keyVerifications,
verificationTokens,
userDiscoveryAnnouncedUsers,
userDiscoveryUserRelations,
userDiscoveryOtherPromotions,
userDiscoveryOwnPromotions,
userDiscoveryShares,
shortcuts,
shortcutMembers,
];
late final Shape39 contacts = Shape39(
source: i0.VersionedTable(
entityName: 'contacts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(user_id)'],
columns: [
_column_106,
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_211,
_column_212,
_column_213,
_column_214,
_column_215,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape23 groups = Shape23(
source: i0.VersionedTable(
entityName: 'groups',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id)'],
columns: [
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
_column_130,
_column_131,
_column_132,
_column_133,
_column_134,
_column_118,
_column_135,
_column_136,
_column_137,
_column_138,
_column_139,
_column_140,
_column_141,
_column_142,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 mediaFiles = Shape51(
source: i0.VersionedTable(
entityName: 'media_files',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(media_id)'],
columns: [
_column_143,
_column_144,
_column_145,
_column_146,
_column_147,
_column_148,
_column_149,
_column_239,
_column_240,
_column_207,
_column_150,
_column_151,
_column_152,
_column_153,
_column_154,
_column_155,
_column_156,
_column_157,
_column_244,
_column_245,
_column_118,
_column_241,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 messages = Shape25(
source: i0.VersionedTable(
entityName: 'messages',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id)'],
columns: [
_column_158,
_column_159,
_column_160,
_column_144,
_column_161,
_column_162,
_column_163,
_column_164,
_column_165,
_column_153,
_column_166,
_column_167,
_column_168,
_column_169,
_column_118,
_column_170,
_column_171,
_column_172,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 messageHistories = Shape26(
source: i0.VersionedTable(
entityName: 'message_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_173,
_column_174,
_column_175,
_column_161,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 reactions = Shape27(
source: i0.VersionedTable(
entityName: 'reactions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, sender_id, emoji)'],
columns: [_column_174, _column_176, _column_177, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 groupMembers = Shape38(
source: i0.VersionedTable(
entityName: 'group_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id, contact_id)'],
columns: [
_column_158,
_column_178,
_column_179,
_column_180,
_column_209,
_column_210,
_column_181,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape37 receipts = Shape37(
source: i0.VersionedTable(
entityName: 'receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
_column_208,
_column_187,
_column_188,
_column_189,
_column_190,
_column_191,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape30 receivedReceipts = Shape30(
source: i0.VersionedTable(
entityName: 'received_receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [_column_182, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape31 signalIdentityKeyStores = Shape31(
source: i0.VersionedTable(
entityName: 'signal_identity_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_194, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 signalPreKeyStores = Shape32(
source: i0.VersionedTable(
entityName: 'signal_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(pre_key_id)'],
columns: [_column_195, _column_196, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 signalSenderKeyStores = Shape11(
source: i0.VersionedTable(
entityName: 'signal_sender_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(sender_key_name)'],
columns: [_column_197, _column_198],
attachedDatabase: database,
),
alias: null,
);
late final Shape33 signalSessionStores = Shape33(
source: i0.VersionedTable(
entityName: 'signal_session_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_199, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 signalSignedPreKeyStores = Shape50(
source: i0.VersionedTable(
entityName: 'signal_signed_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(signed_pre_key_id)'],
columns: [_column_242, _column_243, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape34 messageActions = Shape34(
source: i0.VersionedTable(
entityName: 'message_actions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, contact_id, type)'],
columns: [_column_174, _column_183, _column_144, _column_200],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 groupHistories = Shape35(
source: i0.VersionedTable(
entityName: 'group_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_history_id)'],
columns: [
_column_201,
_column_158,
_column_202,
_column_203,
_column_204,
_column_205,
_column_206,
_column_144,
_column_200,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape40 keyVerifications = Shape40(
source: i0.VersionedTable(
entityName: 'key_verifications',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_216, _column_183, _column_144, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 verificationTokens = Shape41(
source: i0.VersionedTable(
entityName: 'verification_tokens',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_217, _column_218, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape52 userDiscoveryAnnouncedUsers = Shape52(
source: i0.VersionedTable(
entityName: 'user_discovery_announced_users',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id)'],
columns: [
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_224,
_column_246,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 userDiscoveryUserRelations = Shape43(
source: i0.VersionedTable(
entityName: 'user_discovery_user_relations',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id, from_contact_id)'],
columns: [_column_225, _column_226, _column_227],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 userDiscoveryOtherPromotions = Shape44(
source: i0.VersionedTable(
entityName: 'user_discovery_other_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(from_contact_id, public_id)'],
columns: [
_column_226,
_column_228,
_column_229,
_column_230,
_column_231,
_column_227,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 userDiscoveryOwnPromotions = Shape45(
source: i0.VersionedTable(
entityName: 'user_discovery_own_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_232, _column_183, _column_233],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 userDiscoveryShares = Shape46(
source: i0.VersionedTable(
entityName: 'user_discovery_shares',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_234, _column_235, _column_175],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 shortcuts = Shape47(
source: i0.VersionedTable(
entityName: 'shortcuts',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_173, _column_236, _column_237],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 shortcutMembers = Shape48(
source: i0.VersionedTable(
entityName: 'shortcut_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(shortcut_id, group_id)'],
columns: [_column_238, _column_158],
attachedDatabase: database,
),
alias: null,
);
}
class Shape52 extends i0.VersionedTable {
Shape52({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get announcedUserId =>
columnsByName['announced_user_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<i2.Uint8List> get announcedPublicKey =>
columnsByName['announced_public_key']!
as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<int> get publicId =>
columnsByName['public_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get username =>
columnsByName['username']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get wasShownToTheUser =>
columnsByName['was_shown_to_the_user']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get isHidden =>
columnsByName['is_hidden']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get wasAskedFriends =>
columnsByName['was_asked_friends']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_246(String aliasedName) =>
i1.GeneratedColumn<int>(
'was_asked_friends',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints:
'NOT NULL DEFAULT 0 CHECK (was_asked_friends IN (0, 1))',
defaultValue: const i1.CustomExpression('0'),
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -8561,6 +9038,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -8639,6 +9117,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from15To16(migrator, schema);
return 16;
case 16:
final schema = Schema17(database: database);
final migrator = i1.Migrator(database, schema);
await from16To17(migrator, schema);
return 17;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -8661,6 +9144,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -8678,5 +9162,6 @@ i1.OnUpgrade stepByStep({
from13To14: from13To14,
from14To15: from14To15,
from15To16: from15To16,
from16To17: from16To17,
),
);

View file

@ -2966,6 +2966,66 @@ abstract class AppLocalizations {
/// **'Request'**
String get friendSuggestionsRequest;
/// No description provided for @friendSuggestionsAskFriend.
///
/// In en, this message translates to:
/// **'Ask your friends'**
String get friendSuggestionsAskFriend;
/// No description provided for @askFriendsDialogTitle.
///
/// In en, this message translates to:
/// **'Ask about {username}'**
String askFriendsDialogTitle(Object username);
/// No description provided for @askFriendsDialogDescription.
///
/// In en, this message translates to:
/// **'Select the friends you want to ask about this user:'**
String get askFriendsDialogDescription;
/// No description provided for @askFriendsDialogConfirm.
///
/// In en, this message translates to:
/// **'Ask'**
String get askFriendsDialogConfirm;
/// No description provided for @askFriendsDialogCancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get askFriendsDialogCancel;
/// No description provided for @chatAskAFriendReceivedDescription.
///
/// In en, this message translates to:
/// **'Your friend just got this as a suggestion and wants to know if he knows this person.'**
String get chatAskAFriendReceivedDescription;
/// No description provided for @chatAskAFriendAddedDescription.
///
/// In en, this message translates to:
/// **'You have added this user to your contacts.'**
String get chatAskAFriendAddedDescription;
/// No description provided for @chatAskAFriendHide.
///
/// In en, this message translates to:
/// **'Hide'**
String get chatAskAFriendHide;
/// No description provided for @chatAskAFriendRequest.
///
/// In en, this message translates to:
/// **'Request'**
String get chatAskAFriendRequest;
/// No description provided for @chatAskAFriendUnknownUser.
///
/// In en, this message translates to:
/// **'User {userId}'**
String chatAskAFriendUnknownUser(Object userId);
/// No description provided for @contactUserDiscoveryImagesLeft.
///
/// In en, this message translates to:

View file

@ -1678,6 +1678,43 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get friendSuggestionsRequest => 'Anfragen';
@override
String get friendSuggestionsAskFriend => 'Deine Freunde fragen';
@override
String askFriendsDialogTitle(Object username) {
return 'Nach $username fragen';
}
@override
String get askFriendsDialogDescription =>
'Wähle die Freunde aus, die du zu diesem Nutzer fragen möchtest:';
@override
String get askFriendsDialogConfirm => 'Fragen';
@override
String get askFriendsDialogCancel => 'Abbrechen';
@override
String get chatAskAFriendReceivedDescription =>
'Dein Freund hat diesen Nutzer als Vorschlag erhalten und möchte wissen, ob er diese Person kennt.';
@override
String get chatAskAFriendAddedDescription =>
'Du hast diesen Nutzer zu deinen Kontakten hinzugefügt.';
@override
String get chatAskAFriendHide => 'Ausblenden';
@override
String get chatAskAFriendRequest => 'Anfragen';
@override
String chatAskAFriendUnknownUser(Object userId) {
return 'Nutzer $userId';
}
@override
String contactUserDiscoveryImagesLeft(Object imagesLeft, Object username) {
return 'Es fehlen noch $imagesLeft Bilder bis deine Freunde mit $username geteilt werden.';

View file

@ -1663,6 +1663,43 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get friendSuggestionsRequest => 'Request';
@override
String get friendSuggestionsAskFriend => 'Ask your friends';
@override
String askFriendsDialogTitle(Object username) {
return 'Ask about $username';
}
@override
String get askFriendsDialogDescription =>
'Select the friends you want to ask about this user:';
@override
String get askFriendsDialogConfirm => 'Ask';
@override
String get askFriendsDialogCancel => 'Cancel';
@override
String get chatAskAFriendReceivedDescription =>
'Your friend just got this as a suggestion and wants to know if he knows this person.';
@override
String get chatAskAFriendAddedDescription =>
'You have added this user to your contacts.';
@override
String get chatAskAFriendHide => 'Hide';
@override
String get chatAskAFriendRequest => 'Request';
@override
String chatAskAFriendUnknownUser(Object userId) {
return 'User $userId';
}
@override
String contactUserDiscoveryImagesLeft(Object imagesLeft, Object username) {
return '$imagesLeft more images are needed until your friends are shared with $username.';

@ -1 +1 @@
Subproject commit 3142288ce2597f051f4294cb1b3ef33a1fe23362
Subproject commit 49a063c35d7173082c224cf4da1e8d5eb3978ebc

View file

@ -11,10 +11,12 @@ message AdditionalMessageData {
LINK = 0;
CONTACTS = 1;
RESTORED_FLAME_COUNTER = 2;
ASK_ABOUT_USER = 3;
}
Type type = 1;
optional string link = 2;
repeated SharedContact contacts = 3;
optional int64 restored_flame_counter = 4;
optional int64 ask_about_user_id = 5;
}

View file

@ -105,6 +105,7 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
$core.String? link,
$core.Iterable<SharedContact>? contacts,
$fixnum.Int64? restoredFlameCounter,
$fixnum.Int64? askAboutUserId,
}) {
final result = create();
if (type != null) result.type = type;
@ -112,6 +113,7 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
if (contacts != null) result.contacts.addAll(contacts);
if (restoredFlameCounter != null)
result.restoredFlameCounter = restoredFlameCounter;
if (askAboutUserId != null) result.askAboutUserId = askAboutUserId;
return result;
}
@ -133,6 +135,7 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
..pPM<SharedContact>(3, _omitFieldNames ? '' : 'contacts',
subBuilder: SharedContact.create)
..aInt64(4, _omitFieldNames ? '' : 'restoredFlameCounter')
..aInt64(5, _omitFieldNames ? '' : 'askAboutUserId')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -184,6 +187,15 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
$core.bool hasRestoredFlameCounter() => $_has(3);
@$pb.TagNumber(4)
void clearRestoredFlameCounter() => $_clearField(4);
@$pb.TagNumber(5)
$fixnum.Int64 get askAboutUserId => $_getI64(4);
@$pb.TagNumber(5)
set askAboutUserId($fixnum.Int64 value) => $_setInt64(4, value);
@$pb.TagNumber(5)
$core.bool hasAskAboutUserId() => $_has(4);
@$pb.TagNumber(5)
void clearAskAboutUserId() => $_clearField(5);
}
const $core.bool _omitFieldNames =

View file

@ -22,16 +22,19 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum {
static const AdditionalMessageData_Type RESTORED_FLAME_COUNTER =
AdditionalMessageData_Type._(
2, _omitEnumNames ? '' : 'RESTORED_FLAME_COUNTER');
static const AdditionalMessageData_Type ASK_ABOUT_USER =
AdditionalMessageData_Type._(3, _omitEnumNames ? '' : 'ASK_ABOUT_USER');
static const $core.List<AdditionalMessageData_Type> values =
<AdditionalMessageData_Type>[
LINK,
CONTACTS,
RESTORED_FLAME_COUNTER,
ASK_ABOUT_USER,
];
static final $core.List<AdditionalMessageData_Type?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 2);
$pb.ProtobufEnum.$_initByValueList(values, 3);
static AdditionalMessageData_Type? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];

View file

@ -67,11 +67,21 @@ const AdditionalMessageData$json = {
'10': 'restoredFlameCounter',
'17': true
},
{
'1': 'ask_about_user_id',
'3': 5,
'4': 1,
'5': 3,
'9': 2,
'10': 'askAboutUserId',
'17': true
},
],
'4': [AdditionalMessageData_Type$json],
'8': [
{'1': '_link'},
{'1': '_restored_flame_counter'},
{'1': '_ask_about_user_id'},
],
};
@ -82,6 +92,7 @@ const AdditionalMessageData_Type$json = {
{'1': 'LINK', '2': 0},
{'1': 'CONTACTS', '2': 1},
{'1': 'RESTORED_FLAME_COUNTER', '2': 2},
{'1': 'ASK_ABOUT_USER', '2': 3},
],
};
@ -90,6 +101,7 @@ final $typed_data.Uint8List additionalMessageDataDescriptor = $convert.base64Dec
'ChVBZGRpdGlvbmFsTWVzc2FnZURhdGESLwoEdHlwZRgBIAEoDjIbLkFkZGl0aW9uYWxNZXNzYW'
'dlRGF0YS5UeXBlUgR0eXBlEhcKBGxpbmsYAiABKAlIAFIEbGlua4gBARIqCghjb250YWN0cxgD'
'IAMoCzIOLlNoYXJlZENvbnRhY3RSCGNvbnRhY3RzEjkKFnJlc3RvcmVkX2ZsYW1lX2NvdW50ZX'
'IYBCABKANIAVIUcmVzdG9yZWRGbGFtZUNvdW50ZXKIAQEiOgoEVHlwZRIICgRMSU5LEAASDAoI'
'Q09OVEFDVFMQARIaChZSRVNUT1JFRF9GTEFNRV9DT1VOVEVSEAJCBwoFX2xpbmtCGQoXX3Jlc3'
'RvcmVkX2ZsYW1lX2NvdW50ZXI=');
'IYBCABKANIAVIUcmVzdG9yZWRGbGFtZUNvdW50ZXKIAQESLgoRYXNrX2Fib3V0X3VzZXJfaWQY'
'BSABKANIAlIOYXNrQWJvdXRVc2VySWSIAQEiTgoEVHlwZRIICgRMSU5LEAASDAoIQ09OVEFDVF'
'MQARIaChZSRVNUT1JFRF9GTEFNRV9DT1VOVEVSEAISEgoOQVNLX0FCT1VUX1VTRVIQA0IHCgVf'
'bGlua0IZChdfcmVzdG9yZWRfZmxhbWVfY291bnRlckIUChJfYXNrX2Fib3V0X3VzZXJfaWQ=');

View file

@ -344,6 +344,52 @@ Future<void> insertAndSendContactShareMessage(
);
}
Future<void> insertAndSendAskAboutUserMessage(
int contactId,
int askAboutUserId,
) async {
final directChat = await twonlyDB.groupsDao.createOrGetDirectChat(contactId);
if (directChat == null) {
Log.error('Failed to get or create direct chat group for contact $contactId');
return;
}
final groupId = directChat.groupId;
final additionalMessageData = AdditionalMessageData(
type: AdditionalMessageData_Type.ASK_ABOUT_USER,
askAboutUserId: Int64(askAboutUserId),
);
final message = await twonlyDB.messagesDao.insertMessage(
MessagesCompanion(
groupId: Value(groupId),
type: Value(MessageType.askAboutUser.name),
additionalMessageData: Value(additionalMessageData.writeToBuffer()),
),
);
if (message == null) {
Log.error('Could not insert message into database');
return;
}
final encryptedContent = pb.EncryptedContent(
additionalDataMessage: pb.EncryptedContent_AdditionalDataMessage(
senderMessageId: message.messageId,
additionalMessageData: additionalMessageData.writeToBuffer(),
timestamp: Int64(message.createdAt.millisecondsSinceEpoch),
type: MessageType.askAboutUser.name,
),
);
await sendCipherTextToGroup(
groupId,
encryptedContent,
messageId: message.messageId,
);
}
Future<void> sendCipherTextToGroup(
String groupId,
pb.EncryptedContent encryptedContent, {

View file

@ -42,9 +42,24 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
@override
void initState() {
super.initState();
if (widget.group != null) {
initAsync();
}
Future<void> initAsync() async {
var group = widget.group;
var contact = widget.contact;
if (group?.isDirectChat == true) {
final members = await twonlyDB.groupsDao.getGroupContact(group!.groupId);
if (members.isNotEmpty) {
contact = members.first;
group = null;
}
}
if (group != null) {
_streamAllVerified = twonlyDB.keyVerificationDao
.watchAllGroupMembersVerified(widget.group!.groupId)
.watchAllGroupMembersVerified(group.groupId)
.listen((update) {
if (!mounted) return;
setState(() {
@ -58,9 +73,9 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
}
});
});
} else if (widget.contact != null) {
} else if (contact != null) {
_streamContactVerification = twonlyDB.keyVerificationDao
.watchContactVerification(widget.contact!.userId)
.watchContactVerification(contact.userId)
.listen((update) {
if (!mounted) return;
setState(() {
@ -69,7 +84,7 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
});
_streamTransferredTrust = twonlyDB.keyVerificationDao
.watchTransferredTrustVerifications(widget.contact!.userId)
.watchTransferredTrustVerifications(contact.userId)
.listen((update) {
if (!mounted) return;
setState(() {

View file

@ -13,6 +13,7 @@ import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_reaction_row.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart';
@ -137,12 +138,24 @@ class _ChatListEntryState extends State<ChatListEntry> {
if (widget.message.type == MessageType.contacts.name) {
return ChatContactsEntry(
message: widget.message,
borderRadius: borderRadius,
info: info,
);
}
if (widget.message.type == MessageType.restoreFlameCounter.name) {
return ChatFlameRestoredEntry(
message: widget.message,
borderRadius: borderRadius,
info: info,
);
}
if (widget.message.type == MessageType.askAboutUser.name) {
return ChatAskAFriendEntry(
message: widget.message,
borderRadius: borderRadius,
info: info,
);
}

View file

@ -0,0 +1,324 @@
import 'dart:convert';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
class ChatAskAFriendEntry extends StatefulWidget {
const ChatAskAFriendEntry({
required this.message,
required this.borderRadius,
required this.info,
super.key,
});
final Message message;
final BorderRadiusGeometry borderRadius;
final BubbleInfo info;
@override
State<ChatAskAFriendEntry> createState() => _ChatAskAFriendEntryState();
}
class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
bool _isLoading = false;
String? _username;
bool _isSent = false;
AdditionalMessageData? _data;
@override
void initState() {
super.initState();
_isSent = widget.message.senderId == null;
if (widget.message.additionalMessageData != null) {
try {
_data = AdditionalMessageData.fromBuffer(
widget.message.additionalMessageData!,
);
} catch (e) {
_data = null;
}
}
_loadUser();
}
Future<void> _loadUser() async {
if (_data == null || !_data!.hasAskAboutUserId()) return;
final userId = _data!.askAboutUserId.toInt();
setState(() {
_isLoading = true;
});
try {
if (_isSent) {
// Try getting from contacts
final contact = await twonlyDB.contactsDao.getContactById(userId);
if (contact != null) {
_username = contact.displayName ?? contact.username;
} else {
// Try getting from announced users
final announced = await twonlyDB.userDiscoveryDao
.getAnnouncedUserById(userId);
if (announced != null && announced.username != null) {
_username = announced.username;
}
}
} else {
// Receiver side: try contacts first
final contact = await twonlyDB.contactsDao.getContactById(userId);
if (contact != null) {
_username = contact.displayName ?? contact.username;
} else {
// Fetch from API
final userdata = await apiService.getUserById(userId);
if (userdata != null) {
_username = utf8.decode(userdata.username);
}
}
}
} catch (e) {
Log.error(e);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _hideUser() async {
if (_data == null || !_data!.hasAskAboutUserId()) return;
await twonlyDB.userDiscoveryDao.updateAnnouncedUser(
_data!.askAboutUserId.toInt(),
const UserDiscoveryAnnouncedUsersCompanion(
isHidden: Value(true),
),
);
}
Future<void> _requestUser() async {
if (_data == null || !_data!.hasAskAboutUserId()) return;
setState(() {
_isLoading = true;
});
try {
final userId = _data!.askAboutUserId.toInt();
final userdata = await apiService.getUserById(userId);
if (userdata != null) {
await twonlyDB.contactsDao.insertOnConflictUpdate(
ContactsCompanion(
username: Value(utf8.decode(userdata.username)),
userId: Value(userdata.userId.toInt()),
requested: const Value(false),
blocked: const Value(false),
deletedByUser: const Value(false),
),
);
await importSignalContactAndCreateRequest(userdata);
}
} catch (e) {
Log.error(e);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
if (_data == null || !_data!.hasAskAboutUserId()) {
return const SizedBox.shrink();
}
final userId = _data!.askAboutUserId.toInt();
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: widget.info.color,
borderRadius: widget.borderRadius,
),
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
StreamBuilder<Contact?>(
stream: twonlyDB.contactsDao.watchContact(userId),
builder: (context, snapshot) {
final contactInDb = snapshot.data;
return GestureDetector(
onTap: () {
if (contactInDb != null) {
context.push(Routes.profileContact(userId));
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AvatarIcon(
contactId: userId,
fontSize: 12,
),
const SizedBox(width: 8),
if (_isLoading && _username == null)
const Padding(
padding: EdgeInsets.only(right: 8),
child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
)
else if (_username != null)
Flexible(
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
_username!,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: widget.info.textColor,
),
),
),
)
else
Flexible(
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
context.lang.chatAskAFriendUnknownUser(
userId.toString(),
),
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: widget.info.textColor,
),
),
),
),
if (contactInDb != null) ...[
Opacity(
opacity: 0.5,
child: FaIcon(
FontAwesomeIcons.chevronRight,
size: 10,
color: widget.info.textColor,
),
),
const SizedBox(width: 8),
] else ...[
const SizedBox(width: 4),
],
],
),
),
);
},
),
if (!_isSent) ...[
const SizedBox(height: 12),
Text(
context.lang.chatAskAFriendReceivedDescription,
style: TextStyle(
fontSize: 12,
color: widget.info.textColor,
),
),
] else ...[
StreamBuilder<Contact?>(
stream: twonlyDB.contactsDao.watchContact(userId),
builder: (context, snapshot) {
final contactInDb = snapshot.data;
if (contactInDb != null) {
return Text(
context.lang.chatAskAFriendAddedDescription,
style: TextStyle(
fontSize: 12,
color: widget.info.textColor,
),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isLoading ? null : _hideUser,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
),
child: Text(
context.lang.chatAskAFriendHide,
style: TextStyle(
fontSize: 12,
color: widget.info.textColor,
),
),
),
const SizedBox(width: 8),
FilledButton(
style: FilledButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
).merge(secondaryGreyButtonStyle(context)),
onPressed: _isLoading ? null : _requestUser,
child: _isLoading
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(
context.lang.chatAskAFriendRequest,
style: const TextStyle(fontSize: 12),
),
),
],
);
},
),
],
],
),
),
);
}
}

View file

@ -18,10 +18,14 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/c
class ChatContactsEntry extends StatefulWidget {
const ChatContactsEntry({
required this.message,
required this.borderRadius,
required this.info,
super.key,
});
final Message message;
final BorderRadiusGeometry borderRadius;
final BubbleInfo info;
@override
State<ChatContactsEntry> createState() => _ChatContactsEntryState();
@ -46,23 +50,14 @@ class _ChatContactsEntryState extends State<ChatContactsEntry> {
return const SizedBox.shrink();
}
final info = getBubbleInfo(
context,
widget.message,
null,
null,
null,
0,
);
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: info.color,
borderRadius: BorderRadius.circular(12),
color: widget.info.color,
borderRadius: widget.borderRadius,
),
child: IntrinsicWidth(
child: Column(

View file

@ -4,13 +4,19 @@ import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/better_text.element.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
class ChatFlameRestoredEntry extends StatelessWidget {
const ChatFlameRestoredEntry({
required this.message,
required this.borderRadius,
required this.info,
super.key,
});
final Message message;
final BorderRadiusGeometry borderRadius;
final BubbleInfo info;
@override
Widget build(BuildContext context) {
@ -34,10 +40,10 @@ class ChatFlameRestoredEntry extends StatelessWidget {
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(12),
borderRadius: borderRadius,
),
child: BetterText(
text: context.lang.chatEntryFlameRestored(

View file

@ -5,6 +5,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
@ -82,6 +83,87 @@ class FriendSuggestionsComp extends StatelessWidget {
);
}
Future<void> _askFriends(
BuildContext context,
UserDiscoveryAnnouncedUser user,
List<(Contact, DateTime?)> friends,
) async {
Log.info('Asking friends about user: ${user.announcedUserId}');
final selectedFriends = <int>{};
final username = user.username ?? '';
final result = await showDialog<bool>(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(context.lang.askFriendsDialogTitle(username)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.lang.askFriendsDialogDescription),
const SizedBox(height: 10),
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: friends.map((f) {
final contact = f.$1;
final isSelected =
selectedFriends.contains(contact.userId);
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(contact.displayName ?? contact.username),
value: isSelected,
onChanged: (val) {
setState(() {
if (val == true) {
selectedFriends.add(contact.userId);
} else {
selectedFriends.remove(contact.userId);
}
});
},
);
}).toList(),
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.askFriendsDialogCancel),
),
TextButton(
onPressed: selectedFriends.isEmpty
? null
: () => Navigator.pop(context, true),
child: Text(context.lang.askFriendsDialogConfirm),
),
],
);
},
);
},
);
if (result == true && selectedFriends.isNotEmpty) {
for (final contactId in selectedFriends) {
await insertAndSendAskAboutUserMessage(contactId, user.announcedUserId);
}
await twonlyDB.userDiscoveryDao.updateAnnouncedUser(
user.announcedUserId,
const UserDiscoveryAnnouncedUsersCompanion(
wasAskedFriends: Value(true),
),
);
}
}
@override
Widget build(BuildContext context) {
if (announcedUsers.isEmpty) return Container();
@ -99,12 +181,31 @@ class FriendSuggestionsComp extends StatelessWidget {
final friendsList = buildFriendsListText(context, friends);
return ListTile(
return Padding(
key: ValueKey(user.announcedUserId),
contentPadding: EdgeInsets.zero,
title: Text(substringBy(user.username!, 25)),
subtitle: StreamBuilder(
stream: twonlyDB.groupsDao.watchNonDirectGroupsForMember(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const AvatarIcon(
fontSize: 17,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
substringBy(user.username!, 25),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 4),
StreamBuilder<List<Group>>(
stream: twonlyDB.groupsDao
.watchNonDirectGroupsForMember(
user.announcedUserId,
),
builder: (context, snapshot) {
@ -114,7 +215,9 @@ class FriendSuggestionsComp extends StatelessWidget {
context,
context.lang.friendSuggestionsGroupMemberIn(
joinWithAnd(
snapshot.data!.map((g) => '*${g.groupName}*').toList(),
snapshot.data!
.map((g) => '*${g.groupName}*')
.toList(),
context.lang.andWord,
),
),
@ -123,29 +226,80 @@ class FriendSuggestionsComp extends StatelessWidget {
return RichText(
text: TextSpan(
children: text,
style: const TextStyle(fontSize: 11),
style: TextStyle(
fontSize: 11,
color: context.color.onSurfaceVariant,
),
),
);
},
),
leading: const AvatarIcon(
fontSize: 17,
],
),
trailing: Row(
),
const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!user.wasAskedFriends) ...[
SizedBox(
height: 28,
child: FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.only(
right: 8,
left: 4,
),
).merge(secondaryGreyButtonStyle(context)),
onPressed: () => _askFriends(context, user, friends),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 6,
),
child: FaIcon(
FontAwesomeIcons.circleQuestion,
size: 12,
),
),
Text(
context.lang.friendSuggestionsAskFriend,
style: const TextStyle(fontSize: 10),
),
],
),
),
),
const SizedBox(height: 6),
],
SizedBox(
height: 26,
child: FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.only(right: 8, left: 4),
padding: const EdgeInsets.only(
right: 8,
left: 4,
),
).merge(secondaryGreyButtonStyle(context)),
onPressed: () =>
_requestAnnouncedUser(context, user),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: FaIcon(FontAwesomeIcons.userPlus, size: 12),
padding: EdgeInsets.symmetric(
horizontal: 6,
),
child: FaIcon(
FontAwesomeIcons.userPlus,
size: 12,
),
),
Text(
context.lang.friendSuggestionsRequest,
@ -153,9 +307,10 @@ class FriendSuggestionsComp extends StatelessWidget {
),
],
),
onPressed: () => _requestAnnouncedUser(context, user),
),
),
],
),
IconButton(
style: IconButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
@ -167,6 +322,8 @@ class FriendSuggestionsComp extends StatelessWidget {
),
],
),
],
),
);
}),
],

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart';
@ -30,7 +31,7 @@ class UserDiscoverySetupState {
this.sharePromotion = true,
this.isManualApprovalEnabled = false,
this.threshold = 3,
this.requiredSendImages = 4,
this.requiredSendImages = kReleaseMode ? 4 : 0,
});
bool wasChanged = false;
@ -122,7 +123,6 @@ class UserDiscoverySetupComp extends StatelessWidget {
),
const SizedBox(height: 24),
// First description text (centered, no card/title/icon)
RichText(
text: TextSpan(
children: formattedText(
@ -278,7 +278,6 @@ class UserDiscoverySetupComp extends StatelessWidget {
),
const SizedBox(height: 24),
// Checkboxes / settings Card
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@ -370,7 +369,6 @@ class UserDiscoverySetupComp extends StatelessWidget {
),
const SizedBox(height: 24),
// First description text (centered, no card/title/icon)
RichText(
text: TextSpan(
children: formattedText(
@ -542,7 +540,6 @@ class UserDiscoverySetupComp extends StatelessWidget {
),
const SizedBox(height: 24),
// Checkboxes / settings Card
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(

View file

@ -20,6 +20,7 @@ import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15;
import 'schema_v16.dart' as v16;
import 'schema_v17.dart' as v17;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -57,6 +58,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v15.DatabaseAtV15(db);
case 16:
return v16.DatabaseAtV16(db);
case 17:
return v17.DatabaseAtV17(db);
default:
throw MissingSchemaException(version, versions);
}
@ -79,5 +82,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
14,
15,
16,
17,
];
}

File diff suppressed because it is too large Load diff