implement manual approval and fix bug

This commit is contained in:
otsmr 2026-04-29 12:11:07 +02:00
parent f0741bfdc1
commit c54265495c
22 changed files with 356 additions and 110 deletions

View file

@ -3,7 +3,7 @@
## 0.1.9 ## 0.1.9
- New: Feature to find friends without a phone number - New: Feature to find friends without a phone number
- New: The verification state is now transferred to the scanned user. - New: The verification state is now transferred to the scanned user
- New: Registration setup to configure the most important configurations - New: Registration setup to configure the most important configurations
- Improved: FAQ is now in the app rather than opening in the browser - Improved: FAQ is now in the app rather than opening in the browser
- Fix: Many smaller issues - Fix: Many smaller issues

View file

@ -135,27 +135,35 @@ class ContactsDao extends DatabaseAccessor<TwonlyDB> with _$ContactsDaoMixin {
} }
Stream<List<Contact>> watchContactsAnnouncedViaUserDiscovery() { Stream<List<Contact>> watchContactsAnnouncedViaUserDiscovery() {
return (select(contacts)..where( return (select(contacts)..where((t) {
(t) => var expr = t.userDiscoveryVersion.isNotNull() &
t.userDiscoveryVersion.isNotNull() & t.userDiscoveryExcluded.equals(false) &
t.userDiscoveryExcluded.equals(false) & t.mediaSendCounter.isBiggerOrEqualValue(
t.mediaSendCounter.isBiggerOrEqualValue( userService.currentUser.requiredSendImages,
userService.currentUser.requiredSendImages, );
),
)) if (userService.currentUser.userDiscoveryRequiresManualApproval) {
.watch(); expr = expr & t.userDiscoveryManualApproved.equals(true);
}
return expr;
})).watch();
} }
Future<List<Contact>> getContactsAnnouncedViaUserDiscovery() async { Future<List<Contact>> getContactsAnnouncedViaUserDiscovery() async {
return (select(contacts)..where( return (select(contacts)..where((t) {
(t) => var expr = t.userDiscoveryVersion.isNotNull() &
t.userDiscoveryVersion.isNotNull() & t.userDiscoveryExcluded.equals(false) &
t.userDiscoveryExcluded.equals(false) & t.mediaSendCounter.isBiggerOrEqualValue(
t.mediaSendCounter.isBiggerOrEqualValue( userService.currentUser.requiredSendImages,
userService.currentUser.requiredSendImages, );
),
)) if (userService.currentUser.userDiscoveryRequiresManualApproval) {
.get(); expr = expr & t.userDiscoveryManualApproved.equals(true);
}
return expr;
})).get();
} }
Stream<List<Contact>> watchAllContacts() { Stream<List<Contact>> watchAllContacts() {

View file

@ -197,7 +197,7 @@
"name": "user_discovery_manual_approved", "name": "user_discovery_manual_approved",
"getter_name": "userDiscoveryManualApproved", "getter_name": "userDiscoveryManualApproved",
"moor_type": "bool", "moor_type": "bool",
"nullable": false, "nullable": true,
"customConstraints": null, "customConstraints": null,
"defaultConstraints": "CHECK (\"user_discovery_manual_approved\" IN (0, 1))", "defaultConstraints": "CHECK (\"user_discovery_manual_approved\" IN (0, 1))",
"dialectAwareDefaultConstraints": { "dialectAwareDefaultConstraints": {
@ -2557,7 +2557,7 @@
"sql": [ "sql": [
{ {
"dialect": "sqlite", "dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"contacts\" (\"user_id\" INTEGER NOT NULL, \"username\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"nick_name\" TEXT NULL, \"avatar_svg_compressed\" BLOB NULL, \"sender_profile_counter\" INTEGER NOT NULL DEFAULT 0, \"accepted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"accepted\" IN (0, 1)), \"deleted_by_user\" INTEGER NOT NULL DEFAULT 0 CHECK (\"deleted_by_user\" IN (0, 1)), \"requested\" INTEGER NOT NULL DEFAULT 0 CHECK (\"requested\" IN (0, 1)), \"blocked\" INTEGER NOT NULL DEFAULT 0 CHECK (\"blocked\" IN (0, 1)), \"verified\" INTEGER NOT NULL DEFAULT 0 CHECK (\"verified\" IN (0, 1)), \"account_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"account_deleted\" IN (0, 1)), \"created_at\" INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), \"user_discovery_version\" BLOB NULL, \"user_discovery_excluded\" INTEGER NOT NULL DEFAULT 0 CHECK (\"user_discovery_excluded\" IN (0, 1)), \"user_discovery_manual_approved\" INTEGER NOT NULL DEFAULT 0 CHECK (\"user_discovery_manual_approved\" IN (0, 1)), \"media_send_counter\" INTEGER NOT NULL DEFAULT 0, \"media_received_counter\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"user_id\"));" "sql": "CREATE TABLE IF NOT EXISTS \"contacts\" (\"user_id\" INTEGER NOT NULL, \"username\" TEXT NOT NULL, \"display_name\" TEXT NULL, \"nick_name\" TEXT NULL, \"avatar_svg_compressed\" BLOB NULL, \"sender_profile_counter\" INTEGER NOT NULL DEFAULT 0, \"accepted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"accepted\" IN (0, 1)), \"deleted_by_user\" INTEGER NOT NULL DEFAULT 0 CHECK (\"deleted_by_user\" IN (0, 1)), \"requested\" INTEGER NOT NULL DEFAULT 0 CHECK (\"requested\" IN (0, 1)), \"blocked\" INTEGER NOT NULL DEFAULT 0 CHECK (\"blocked\" IN (0, 1)), \"verified\" INTEGER NOT NULL DEFAULT 0 CHECK (\"verified\" IN (0, 1)), \"account_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"account_deleted\" IN (0, 1)), \"created_at\" INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), \"user_discovery_version\" BLOB NULL, \"user_discovery_excluded\" INTEGER NOT NULL DEFAULT 0 CHECK (\"user_discovery_excluded\" IN (0, 1)), \"user_discovery_manual_approved\" INTEGER NULL DEFAULT 0 CHECK (\"user_discovery_manual_approved\" IN (0, 1)), \"media_send_counter\" INTEGER NOT NULL DEFAULT 0, \"media_received_counter\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"user_id\"));"
} }
] ]
}, },

View file

@ -30,7 +30,7 @@ class Contacts extends Table {
boolean().withDefault(const Constant(false))(); boolean().withDefault(const Constant(false))();
BoolColumn get userDiscoveryManualApproved => BoolColumn get userDiscoveryManualApproved =>
boolean().withDefault(const Constant(false))(); boolean().nullable().withDefault(const Constant(false))();
IntColumn get mediaSendCounter => integer().withDefault(const Constant(0))(); IntColumn get mediaSendCounter => integer().withDefault(const Constant(0))();
IntColumn get mediaReceivedCounter => IntColumn get mediaReceivedCounter =>

View file

@ -207,7 +207,7 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
GeneratedColumn<bool>( GeneratedColumn<bool>(
'user_discovery_manual_approved', 'user_discovery_manual_approved',
aliasedName, aliasedName,
false, true,
type: DriftSqlType.bool, type: DriftSqlType.bool,
requiredDuringInsert: false, requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways( defaultConstraints: GeneratedColumn.constraintIsAlways(
@ -483,7 +483,7 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
userDiscoveryManualApproved: attachedDatabase.typeMapping.read( userDiscoveryManualApproved: attachedDatabase.typeMapping.read(
DriftSqlType.bool, DriftSqlType.bool,
data['${effectivePrefix}user_discovery_manual_approved'], data['${effectivePrefix}user_discovery_manual_approved'],
)!, ),
mediaSendCounter: attachedDatabase.typeMapping.read( mediaSendCounter: attachedDatabase.typeMapping.read(
DriftSqlType.int, DriftSqlType.int,
data['${effectivePrefix}media_send_counter'], data['${effectivePrefix}media_send_counter'],
@ -517,7 +517,7 @@ class Contact extends DataClass implements Insertable<Contact> {
final DateTime createdAt; final DateTime createdAt;
final Uint8List? userDiscoveryVersion; final Uint8List? userDiscoveryVersion;
final bool userDiscoveryExcluded; final bool userDiscoveryExcluded;
final bool userDiscoveryManualApproved; final bool? userDiscoveryManualApproved;
final int mediaSendCounter; final int mediaSendCounter;
final int mediaReceivedCounter; final int mediaReceivedCounter;
const Contact({ const Contact({
@ -536,7 +536,7 @@ class Contact extends DataClass implements Insertable<Contact> {
required this.createdAt, required this.createdAt,
this.userDiscoveryVersion, this.userDiscoveryVersion,
required this.userDiscoveryExcluded, required this.userDiscoveryExcluded,
required this.userDiscoveryManualApproved, this.userDiscoveryManualApproved,
required this.mediaSendCounter, required this.mediaSendCounter,
required this.mediaReceivedCounter, required this.mediaReceivedCounter,
}); });
@ -566,9 +566,11 @@ class Contact extends DataClass implements Insertable<Contact> {
map['user_discovery_version'] = Variable<Uint8List>(userDiscoveryVersion); map['user_discovery_version'] = Variable<Uint8List>(userDiscoveryVersion);
} }
map['user_discovery_excluded'] = Variable<bool>(userDiscoveryExcluded); map['user_discovery_excluded'] = Variable<bool>(userDiscoveryExcluded);
map['user_discovery_manual_approved'] = Variable<bool>( if (!nullToAbsent || userDiscoveryManualApproved != null) {
userDiscoveryManualApproved, map['user_discovery_manual_approved'] = Variable<bool>(
); userDiscoveryManualApproved,
);
}
map['media_send_counter'] = Variable<int>(mediaSendCounter); map['media_send_counter'] = Variable<int>(mediaSendCounter);
map['media_received_counter'] = Variable<int>(mediaReceivedCounter); map['media_received_counter'] = Variable<int>(mediaReceivedCounter);
return map; return map;
@ -599,7 +601,10 @@ class Contact extends DataClass implements Insertable<Contact> {
? const Value.absent() ? const Value.absent()
: Value(userDiscoveryVersion), : Value(userDiscoveryVersion),
userDiscoveryExcluded: Value(userDiscoveryExcluded), userDiscoveryExcluded: Value(userDiscoveryExcluded),
userDiscoveryManualApproved: Value(userDiscoveryManualApproved), userDiscoveryManualApproved:
userDiscoveryManualApproved == null && nullToAbsent
? const Value.absent()
: Value(userDiscoveryManualApproved),
mediaSendCounter: Value(mediaSendCounter), mediaSendCounter: Value(mediaSendCounter),
mediaReceivedCounter: Value(mediaReceivedCounter), mediaReceivedCounter: Value(mediaReceivedCounter),
); );
@ -634,7 +639,7 @@ class Contact extends DataClass implements Insertable<Contact> {
userDiscoveryExcluded: serializer.fromJson<bool>( userDiscoveryExcluded: serializer.fromJson<bool>(
json['userDiscoveryExcluded'], json['userDiscoveryExcluded'],
), ),
userDiscoveryManualApproved: serializer.fromJson<bool>( userDiscoveryManualApproved: serializer.fromJson<bool?>(
json['userDiscoveryManualApproved'], json['userDiscoveryManualApproved'],
), ),
mediaSendCounter: serializer.fromJson<int>(json['mediaSendCounter']), mediaSendCounter: serializer.fromJson<int>(json['mediaSendCounter']),
@ -664,7 +669,7 @@ class Contact extends DataClass implements Insertable<Contact> {
userDiscoveryVersion, userDiscoveryVersion,
), ),
'userDiscoveryExcluded': serializer.toJson<bool>(userDiscoveryExcluded), 'userDiscoveryExcluded': serializer.toJson<bool>(userDiscoveryExcluded),
'userDiscoveryManualApproved': serializer.toJson<bool>( 'userDiscoveryManualApproved': serializer.toJson<bool?>(
userDiscoveryManualApproved, userDiscoveryManualApproved,
), ),
'mediaSendCounter': serializer.toJson<int>(mediaSendCounter), 'mediaSendCounter': serializer.toJson<int>(mediaSendCounter),
@ -688,7 +693,7 @@ class Contact extends DataClass implements Insertable<Contact> {
DateTime? createdAt, DateTime? createdAt,
Value<Uint8List?> userDiscoveryVersion = const Value.absent(), Value<Uint8List?> userDiscoveryVersion = const Value.absent(),
bool? userDiscoveryExcluded, bool? userDiscoveryExcluded,
bool? userDiscoveryManualApproved, Value<bool?> userDiscoveryManualApproved = const Value.absent(),
int? mediaSendCounter, int? mediaSendCounter,
int? mediaReceivedCounter, int? mediaReceivedCounter,
}) => Contact( }) => Contact(
@ -711,8 +716,9 @@ class Contact extends DataClass implements Insertable<Contact> {
? userDiscoveryVersion.value ? userDiscoveryVersion.value
: this.userDiscoveryVersion, : this.userDiscoveryVersion,
userDiscoveryExcluded: userDiscoveryExcluded ?? this.userDiscoveryExcluded, userDiscoveryExcluded: userDiscoveryExcluded ?? this.userDiscoveryExcluded,
userDiscoveryManualApproved: userDiscoveryManualApproved: userDiscoveryManualApproved.present
userDiscoveryManualApproved ?? this.userDiscoveryManualApproved, ? userDiscoveryManualApproved.value
: this.userDiscoveryManualApproved,
mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter, mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter,
mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter, mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter,
); );
@ -852,7 +858,7 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
final Value<DateTime> createdAt; final Value<DateTime> createdAt;
final Value<Uint8List?> userDiscoveryVersion; final Value<Uint8List?> userDiscoveryVersion;
final Value<bool> userDiscoveryExcluded; final Value<bool> userDiscoveryExcluded;
final Value<bool> userDiscoveryManualApproved; final Value<bool?> userDiscoveryManualApproved;
final Value<int> mediaSendCounter; final Value<int> mediaSendCounter;
final Value<int> mediaReceivedCounter; final Value<int> mediaReceivedCounter;
const ContactsCompanion({ const ContactsCompanion({
@ -959,7 +965,7 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
Value<DateTime>? createdAt, Value<DateTime>? createdAt,
Value<Uint8List?>? userDiscoveryVersion, Value<Uint8List?>? userDiscoveryVersion,
Value<bool>? userDiscoveryExcluded, Value<bool>? userDiscoveryExcluded,
Value<bool>? userDiscoveryManualApproved, Value<bool?>? userDiscoveryManualApproved,
Value<int>? mediaSendCounter, Value<int>? mediaSendCounter,
Value<int>? mediaReceivedCounter, Value<int>? mediaReceivedCounter,
}) { }) {
@ -11687,7 +11693,7 @@ typedef $$ContactsTableCreateCompanionBuilder =
Value<DateTime> createdAt, Value<DateTime> createdAt,
Value<Uint8List?> userDiscoveryVersion, Value<Uint8List?> userDiscoveryVersion,
Value<bool> userDiscoveryExcluded, Value<bool> userDiscoveryExcluded,
Value<bool> userDiscoveryManualApproved, Value<bool?> userDiscoveryManualApproved,
Value<int> mediaSendCounter, Value<int> mediaSendCounter,
Value<int> mediaReceivedCounter, Value<int> mediaReceivedCounter,
}); });
@ -11708,7 +11714,7 @@ typedef $$ContactsTableUpdateCompanionBuilder =
Value<DateTime> createdAt, Value<DateTime> createdAt,
Value<Uint8List?> userDiscoveryVersion, Value<Uint8List?> userDiscoveryVersion,
Value<bool> userDiscoveryExcluded, Value<bool> userDiscoveryExcluded,
Value<bool> userDiscoveryManualApproved, Value<bool?> userDiscoveryManualApproved,
Value<int> mediaSendCounter, Value<int> mediaSendCounter,
Value<int> mediaReceivedCounter, Value<int> mediaReceivedCounter,
}); });
@ -12967,7 +12973,7 @@ class $$ContactsTableTableManager
Value<DateTime> createdAt = const Value.absent(), Value<DateTime> createdAt = const Value.absent(),
Value<Uint8List?> userDiscoveryVersion = const Value.absent(), Value<Uint8List?> userDiscoveryVersion = const Value.absent(),
Value<bool> userDiscoveryExcluded = const Value.absent(), Value<bool> userDiscoveryExcluded = const Value.absent(),
Value<bool> userDiscoveryManualApproved = const Value.absent(), Value<bool?> userDiscoveryManualApproved = const Value.absent(),
Value<int> mediaSendCounter = const Value.absent(), Value<int> mediaSendCounter = const Value.absent(),
Value<int> mediaReceivedCounter = const Value.absent(), Value<int> mediaReceivedCounter = const Value.absent(),
}) => ContactsCompanion( }) => ContactsCompanion(
@ -13007,7 +13013,7 @@ class $$ContactsTableTableManager
Value<DateTime> createdAt = const Value.absent(), Value<DateTime> createdAt = const Value.absent(),
Value<Uint8List?> userDiscoveryVersion = const Value.absent(), Value<Uint8List?> userDiscoveryVersion = const Value.absent(),
Value<bool> userDiscoveryExcluded = const Value.absent(), Value<bool> userDiscoveryExcluded = const Value.absent(),
Value<bool> userDiscoveryManualApproved = const Value.absent(), Value<bool?> userDiscoveryManualApproved = const Value.absent(),
Value<int> mediaSendCounter = const Value.absent(), Value<int> mediaSendCounter = const Value.absent(),
Value<int> mediaReceivedCounter = const Value.absent(), Value<int> mediaReceivedCounter = const Value.absent(),
}) => ContactsCompanion.insert( }) => ContactsCompanion.insert(

View file

@ -6294,10 +6294,10 @@ i1.GeneratedColumn<int> _column_213(String aliasedName) =>
i1.GeneratedColumn<int>( i1.GeneratedColumn<int>(
'user_discovery_manual_approved', 'user_discovery_manual_approved',
aliasedName, aliasedName,
false, true,
type: i1.DriftSqlType.int, type: i1.DriftSqlType.int,
$customConstraints: $customConstraints:
'NOT NULL DEFAULT 0 CHECK (user_discovery_manual_approved IN (0, 1))', 'NULL DEFAULT 0 CHECK (user_discovery_manual_approved IN (0, 1))',
defaultValue: const i1.CustomExpression('0'), defaultValue: const i1.CustomExpression('0'),
); );
i1.GeneratedColumn<int> _column_214(String aliasedName) => i1.GeneratedColumn<int> _column_214(String aliasedName) =>

View file

@ -2971,6 +2971,24 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Stop sharing'** /// **'Stop sharing'**
String get userDiscoveryEnabledStopSharing; String get userDiscoveryEnabledStopSharing;
/// No description provided for @userDiscoveryManualApprovalReachedThreshold.
///
/// In en, this message translates to:
/// **'{username} has reached your threshold and now needs your manual approval to be shared with your friends.'**
String userDiscoveryManualApprovalReachedThreshold(Object username);
/// No description provided for @userDiscoveryManualApprovalHideContact.
///
/// In en, this message translates to:
/// **'Hide contact'**
String get userDiscoveryManualApprovalHideContact;
/// No description provided for @userDiscoveryManualApprovalShareContact.
///
/// In en, this message translates to:
/// **'Share contact'**
String get userDiscoveryManualApprovalShareContact;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -1672,4 +1672,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get userDiscoveryEnabledStopSharing => 'Nicht mehr teilen'; String get userDiscoveryEnabledStopSharing => 'Nicht mehr teilen';
@override
String userDiscoveryManualApprovalReachedThreshold(Object username) {
return '$username hat deinen Schwellenwert erreicht und benötigt nur noch eine manuelle Zustimmung, um mit deinen Freunden geteilt zu werden.';
}
@override
String get userDiscoveryManualApprovalHideContact => 'Kontakt verbergen';
@override
String get userDiscoveryManualApprovalShareContact => 'Kontakt teilen';
} }

View file

@ -1656,4 +1656,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get userDiscoveryEnabledStopSharing => 'Stop sharing'; String get userDiscoveryEnabledStopSharing => 'Stop sharing';
@override
String userDiscoveryManualApprovalReachedThreshold(Object username) {
return '$username has reached your threshold and now needs your manual approval to be shared with your friends.';
}
@override
String get userDiscoveryManualApprovalHideContact => 'Hide contact';
@override
String get userDiscoveryManualApprovalShareContact => 'Share contact';
} }

@ -1 +1 @@
Subproject commit dc33fa1bb1e0b9f6d15a0ee36ed37a7f0063f2a0 Subproject commit 492bef11cf6bd472a71bd1c1b3007d731adea433

View file

@ -24,6 +24,7 @@ import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart' import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
as server; as server;
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart'; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart';
import 'package:twonly/src/services/api/client2client/user_discovery.c2c.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart'; import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/api/messages.api.dart';
@ -123,6 +124,7 @@ class ApiService {
unawaited(setupNotificationWithUsers()); unawaited(setupNotificationWithUsers());
unawaited(signalHandleNewServerConnection()); unawaited(signalHandleNewServerConnection());
resetResyncedUsers(); resetResyncedUsers();
resetUserDiscoveryRequestUpdates();
unawaited(fetchGroupStatesForUnjoinedGroups()); unawaited(fetchGroupStatesForUnjoinedGroups());
unawaited(fetchMissingGroupPublicKey()); unawaited(fetchMissingGroupPublicKey());
unawaited(checkForDeletedUsernames()); unawaited(checkForDeletedUsernames());

View file

@ -185,7 +185,6 @@ Future<int?> checkForProfileUpdate(
.getSingleOrNull(); .getSingleOrNull();
if (contact != null) { if (contact != null) {
if (contact.senderProfileCounter < senderProfileCounter) { if (contact.senderProfileCounter < senderProfileCounter) {
Log.info('${contact.senderProfileCounter} < $senderProfileCounter');
await sendCipherText( await sendCipherText(
fromUserId, fromUserId,
EncryptedContent( EncryptedContent(

View file

@ -8,6 +8,10 @@ import 'package:twonly/src/utils/log.dart';
final _requestedUpdates = <int>{}; final _requestedUpdates = <int>{};
void resetUserDiscoveryRequestUpdates() {
_requestedUpdates.clear();
}
Future<void> checkForUserDiscoveryChanges( Future<void> checkForUserDiscoveryChanges(
int fromUserId, int fromUserId,
List<int> receivedVersion, List<int> receivedVersion,
@ -19,7 +23,7 @@ Future<void> checkForUserDiscoveryChanges(
if (currentVersion != null) { if (currentVersion != null) {
if (_requestedUpdates.contains(fromUserId)) { if (_requestedUpdates.contains(fromUserId)) {
/// Only request a new version once per app session // Only request a new version once per app session
return; return;
} }
Log.info('Having old version from contact. Requesting new version.'); Log.info('Having old version from contact. Requesting new version.');
@ -46,12 +50,10 @@ Future<void> handleUserDiscoveryRequest(
return; return;
} }
final contact = await twonlyDB.contactsDao.getContactById(fromUserId); final contact = await twonlyDB.contactsDao.getContactById(fromUserId);
if (contact == null) return;
if (contact.mediaSendCounter < userService.currentUser.requiredSendImages || if (!UserDiscoveryService.isContactAllowed(contact)) {
contact.userDiscoveryExcluded) {
Log.warn( Log.warn(
'Got a request to update user discovery, but mediaSendCounter (${contact.mediaSendCounter}) < ${userService.currentUser.requiredSendImages} or user is excluded ${contact.userDiscoveryExcluded}', 'Got a request to update user discovery, but mediaSendCounter (${contact?.mediaSendCounter}) < ${userService.currentUser.requiredSendImages} or user is excluded ${contact?.userDiscoveryExcluded}',
); );
return; return;
} }

View file

@ -352,10 +352,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
if (userService.currentUser.isUserDiscoveryEnabled && messageId != null) { if (userService.currentUser.isUserDiscoveryEnabled && messageId != null) {
final contact = await twonlyDB.contactsDao.getContactById(contactId); final contact = await twonlyDB.contactsDao.getContactById(contactId);
if (contact != null && if (UserDiscoveryService.isContactAllowed(contact)) {
contact.mediaSendCounter >=
userService.currentUser.requiredSendImages &&
!contact.userDiscoveryExcluded) {
final version = await UserDiscoveryService.getCurrentVersion(); final version = await UserDiscoveryService.getCurrentVersion();
if (version != null) { if (version != null) {
encryptedContent.senderUserDiscoveryVersion = version; encryptedContent.senderUserDiscoveryVersion = version;

View file

@ -46,6 +46,31 @@ class UserDiscoveryService {
} }
} }
static bool isContactAllowed(Contact? c) {
if (c == null) return false;
final u = userService.currentUser;
// Only accepted users are allowed.
if (!c.accepted || c.blocked) return false;
if (c.mediaSendCounter < u.requiredSendImages) return false;
if (c.userDiscoveryExcluded) return false;
if (u.userDiscoveryRequiresManualApproval &&
(c.userDiscoveryManualApproved == null ||
!c.userDiscoveryManualApproved!)) {
return false;
}
return true;
}
static bool shouldRequestManualApproval(Contact c) {
final u = userService.currentUser;
if (!u.isUserDiscoveryEnabled) return false;
if (c.mediaSendCounter < u.requiredSendImages) return false;
if (c.userDiscoveryExcluded) return false;
if (!u.userDiscoveryRequiresManualApproval) return false;
if (c.userDiscoveryManualApproved == true) return false;
return true;
}
static Future<void> initializeOrUpdate({ static Future<void> initializeOrUpdate({
required int threshold, required int threshold,
required bool sharePromotion, required bool sharePromotion,

View file

@ -69,6 +69,10 @@ Future<void> handleUserStudyUpload() async {
userService.currentUser.requiredSendImages, userService.currentUser.requiredSendImages,
'user_discovery_threshold': 'user_discovery_threshold':
userService.currentUser.userDiscoveryThreshold, userService.currentUser.userDiscoveryThreshold,
'user_discovery_requires_manual_approval':
userService.currentUser.userDiscoveryRequiresManualApproval,
'user_discovery_share_promotion':
userService.currentUser.userDiscoverySharePromotion,
'user_discovery_count_friends_shared': udFriendsShared.length, 'user_discovery_count_friends_shared': udFriendsShared.length,

View file

@ -18,6 +18,7 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.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_audio_entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/user_discovery_manual_approval.comp.dart';
class MessageInput extends StatefulWidget { class MessageInput extends StatefulWidget {
const MessageInput({ const MessageInput({
@ -198,6 +199,7 @@ class _MessageInputState extends State<MessageInput> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
UserDiscoveryManualApprovalComp(group: widget.group),
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
bottom: 10, bottom: 10,

View file

@ -0,0 +1,112 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/user_discovery.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
class UserDiscoveryManualApprovalComp extends StatefulWidget {
const UserDiscoveryManualApprovalComp({required this.group, super.key});
final Group group;
@override
State<UserDiscoveryManualApprovalComp> createState() =>
_UserDiscoveryManualApprovalCompState();
}
class _UserDiscoveryManualApprovalCompState
extends State<UserDiscoveryManualApprovalComp> {
@override
Widget build(BuildContext context) {
return StreamBuilder<List<(Contact, GroupMember)>>(
stream: twonlyDB.groupsDao.watchGroupMembers(widget.group.groupId),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const SizedBox.shrink();
}
final contactsToApprove = snapshot.data!
.map((e) => e.$1)
.where(UserDiscoveryService.shouldRequestManualApproval)
.toList();
if (contactsToApprove.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: contactsToApprove.map((contact) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AvatarIcon(contactId: contact.userId, fontSize: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
context.lang
.userDiscoveryManualApprovalReachedThreshold(
getContactDisplayName(contact),
),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
await twonlyDB.contactsDao.updateContact(
contact.userId,
const ContactsCompanion(
userDiscoveryExcluded: Value(true),
),
);
},
child: Text(
context.lang.userDiscoveryManualApprovalHideContact,
),
),
FilledButton(
onPressed: () async {
await twonlyDB.contactsDao.updateContact(
contact.userId,
const ContactsCompanion(
userDiscoveryManualApproved: Value(true),
),
);
},
child: Text(
context.lang.userDiscoveryManualApprovalShareContact,
),
),
],
),
],
),
);
}).toList(),
);
},
);
}
}

View file

@ -6,9 +6,9 @@ import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/elements/headline.element.dart'; import 'package:twonly/src/visual/elements/headline.element.dart';
import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart'; import 'package:twonly/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart';
@ -150,9 +150,6 @@ class OpenRequestsListComp extends StatelessWidget {
), ),
...contacts.map((contact) { ...contacts.map((contact) {
Widget? subtitle; Widget? subtitle;
Log.info('Relations count: ${relations.entries.length}');
for (final relation in relations.entries) { for (final relation in relations.entries) {
if (relation.key.announcedUserId == contact.userId) { if (relation.key.announcedUserId == contact.userId) {
subtitle = RichText( subtitle = RichText(
@ -165,11 +162,16 @@ class OpenRequestsListComp extends StatelessWidget {
break; break;
} }
} }
return ListTile( return ListTile(
key: ValueKey(contact.userId), key: ValueKey(contact.userId),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(substringBy(contact.username, 25)), title: Row(
children: [
Text(substringBy(contact.username, 25)),
const SizedBox(width: 3),
VerificationBadgeComp(contact: contact),
],
),
subtitle: subtitle, subtitle: subtitle,
leading: AvatarIcon( leading: AvatarIcon(
contactId: contact.userId, contactId: contact.userId,

View file

@ -306,35 +306,58 @@ class _ContactViewState extends State<ContactView> {
], ],
), ),
if (userService.currentUser.isUserDiscoveryEnabled) if (userService.currentUser.isUserDiscoveryEnabled)
BetterListTile( if (userService.currentUser.userDiscoveryRequiresManualApproval &&
icon: FontAwesomeIcons.usersViewfinder, contact.userDiscoveryManualApproved != true)
text: context.lang.userDiscoverySettingsTitle, BetterListTile(
subtitle: icon: FontAwesomeIcons.usersViewfinder,
!contact.userDiscoveryExcluded && text: context.lang.userDiscoverySettingsTitle,
contact.mediaSendCounter < subtitle: const Text(
userService.currentUser.requiredSendImages 'Contact was not yet manual approved.',
? Text( style: TextStyle(fontSize: 10),
context.lang.contactUserDiscoveryImagesLeft( ),
userService.currentUser.requiredSendImages - trailing: TextButton(
contact.mediaSendCounter, onPressed: () async {
getContactDisplayName(contact), await twonlyDB.contactsDao.updateContact(
),
style: const TextStyle(fontSize: 9),
)
: null,
trailing: Transform.scale(
scale: 0.8,
child: Switch(
value: !contact.userDiscoveryExcluded,
onChanged: (a) async {
await UserDiscoveryService.changeExclusionForContact(
contact.userId, contact.userId,
!a, const ContactsCompanion(
userDiscoveryManualApproved: Value(true),
),
); );
}, },
child: const Text('Approve'),
),
)
else
BetterListTile(
icon: FontAwesomeIcons.usersViewfinder,
text: context.lang.userDiscoverySettingsTitle,
subtitle:
!contact.userDiscoveryExcluded &&
contact.mediaSendCounter <
userService.currentUser.requiredSendImages
? Text(
context.lang.contactUserDiscoveryImagesLeft(
userService.currentUser.requiredSendImages -
contact.mediaSendCounter,
getContactDisplayName(contact),
),
style: const TextStyle(fontSize: 9),
)
: null,
trailing: Transform.scale(
scale: 0.8,
child: Switch(
value: !contact.userDiscoveryExcluded,
onChanged: (a) async {
await UserDiscoveryService.changeExclusionForContact(
contact.userId,
!a,
);
},
),
), ),
), ),
),
BetterListTile( BetterListTile(
icon: FontAwesomeIcons.flag, icon: FontAwesomeIcons.flag,
text: context.lang.reportUser, text: context.lang.reportUser,

View file

@ -566,15 +566,33 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
contact_id, contact_id,
announced_user.user_id announced_user.user_id
); );
// User is known, so add him to thr users relations // User is known, so add him to thr users relations
self.store self.store
.push_new_user_relation( .push_new_user_relation(
contact_id, contact_id,
announced_user, announced_user.clone(),
public_key_verified_timestamp, public_key_verified_timestamp,
) )
.await?; .await?;
// As we no now the public_id from the user, all promotions up to this point are also known, so add these to the relations database as well
let promotions = self
.store
.get_other_promotions_by_public_id(uda.public_id)
.await?;
for promotion in promotions {
self.store
.push_new_user_relation(
promotion.from_contact_id,
announced_user,
promotion.public_key_verified_timestamp,
)
.await?;
return Ok(());
}
Ok(()) Ok(())
} }
Err(err) => Err(UserDiscoveryError::ShamirsSecret(err.to_string())), Err(err) => Err(UserDiscoveryError::ShamirsSecret(err.to_string())),

View file

@ -145,17 +145,17 @@ class Contacts extends Table with TableInfo<Contacts, ContactsData> {
'NOT NULL DEFAULT 0 CHECK (user_discovery_excluded IN (0, 1))', 'NOT NULL DEFAULT 0 CHECK (user_discovery_excluded IN (0, 1))',
defaultValue: const CustomExpression('0'), defaultValue: const CustomExpression('0'),
); );
late final GeneratedColumn<int> late final GeneratedColumn<int> userDiscoveryManualApproved =
userDiscoveryManualApproved = GeneratedColumn<int>( GeneratedColumn<int>(
'user_discovery_manual_approved', 'user_discovery_manual_approved',
aliasedName, aliasedName,
false, true,
type: DriftSqlType.int, type: DriftSqlType.int,
requiredDuringInsert: false, requiredDuringInsert: false,
$customConstraints: $customConstraints:
'NOT NULL DEFAULT 0 CHECK (user_discovery_manual_approved IN (0, 1))', 'NULL DEFAULT 0 CHECK (user_discovery_manual_approved IN (0, 1))',
defaultValue: const CustomExpression('0'), defaultValue: const CustomExpression('0'),
); );
late final GeneratedColumn<int> mediaSendCounter = GeneratedColumn<int>( late final GeneratedColumn<int> mediaSendCounter = GeneratedColumn<int>(
'media_send_counter', 'media_send_counter',
aliasedName, aliasedName,
@ -269,7 +269,7 @@ class Contacts extends Table with TableInfo<Contacts, ContactsData> {
userDiscoveryManualApproved: attachedDatabase.typeMapping.read( userDiscoveryManualApproved: attachedDatabase.typeMapping.read(
DriftSqlType.int, DriftSqlType.int,
data['${effectivePrefix}user_discovery_manual_approved'], data['${effectivePrefix}user_discovery_manual_approved'],
)!, ),
mediaSendCounter: attachedDatabase.typeMapping.read( mediaSendCounter: attachedDatabase.typeMapping.read(
DriftSqlType.int, DriftSqlType.int,
data['${effectivePrefix}media_send_counter'], data['${effectivePrefix}media_send_counter'],
@ -308,7 +308,7 @@ class ContactsData extends DataClass implements Insertable<ContactsData> {
final int createdAt; final int createdAt;
final i2.Uint8List? userDiscoveryVersion; final i2.Uint8List? userDiscoveryVersion;
final int userDiscoveryExcluded; final int userDiscoveryExcluded;
final int userDiscoveryManualApproved; final int? userDiscoveryManualApproved;
final int mediaSendCounter; final int mediaSendCounter;
final int mediaReceivedCounter; final int mediaReceivedCounter;
const ContactsData({ const ContactsData({
@ -327,7 +327,7 @@ class ContactsData extends DataClass implements Insertable<ContactsData> {
required this.createdAt, required this.createdAt,
this.userDiscoveryVersion, this.userDiscoveryVersion,
required this.userDiscoveryExcluded, required this.userDiscoveryExcluded,
required this.userDiscoveryManualApproved, this.userDiscoveryManualApproved,
required this.mediaSendCounter, required this.mediaSendCounter,
required this.mediaReceivedCounter, required this.mediaReceivedCounter,
}); });
@ -361,9 +361,11 @@ class ContactsData extends DataClass implements Insertable<ContactsData> {
); );
} }
map['user_discovery_excluded'] = Variable<int>(userDiscoveryExcluded); map['user_discovery_excluded'] = Variable<int>(userDiscoveryExcluded);
map['user_discovery_manual_approved'] = Variable<int>( if (!nullToAbsent || userDiscoveryManualApproved != null) {
userDiscoveryManualApproved, map['user_discovery_manual_approved'] = Variable<int>(
); userDiscoveryManualApproved,
);
}
map['media_send_counter'] = Variable<int>(mediaSendCounter); map['media_send_counter'] = Variable<int>(mediaSendCounter);
map['media_received_counter'] = Variable<int>(mediaReceivedCounter); map['media_received_counter'] = Variable<int>(mediaReceivedCounter);
return map; return map;
@ -394,7 +396,10 @@ class ContactsData extends DataClass implements Insertable<ContactsData> {
? const Value.absent() ? const Value.absent()
: Value(userDiscoveryVersion), : Value(userDiscoveryVersion),
userDiscoveryExcluded: Value(userDiscoveryExcluded), userDiscoveryExcluded: Value(userDiscoveryExcluded),
userDiscoveryManualApproved: Value(userDiscoveryManualApproved), userDiscoveryManualApproved:
userDiscoveryManualApproved == null && nullToAbsent
? const Value.absent()
: Value(userDiscoveryManualApproved),
mediaSendCounter: Value(mediaSendCounter), mediaSendCounter: Value(mediaSendCounter),
mediaReceivedCounter: Value(mediaReceivedCounter), mediaReceivedCounter: Value(mediaReceivedCounter),
); );
@ -429,7 +434,7 @@ class ContactsData extends DataClass implements Insertable<ContactsData> {
userDiscoveryExcluded: serializer.fromJson<int>( userDiscoveryExcluded: serializer.fromJson<int>(
json['userDiscoveryExcluded'], json['userDiscoveryExcluded'],
), ),
userDiscoveryManualApproved: serializer.fromJson<int>( userDiscoveryManualApproved: serializer.fromJson<int?>(
json['userDiscoveryManualApproved'], json['userDiscoveryManualApproved'],
), ),
mediaSendCounter: serializer.fromJson<int>(json['mediaSendCounter']), mediaSendCounter: serializer.fromJson<int>(json['mediaSendCounter']),
@ -461,7 +466,7 @@ class ContactsData extends DataClass implements Insertable<ContactsData> {
userDiscoveryVersion, userDiscoveryVersion,
), ),
'userDiscoveryExcluded': serializer.toJson<int>(userDiscoveryExcluded), 'userDiscoveryExcluded': serializer.toJson<int>(userDiscoveryExcluded),
'userDiscoveryManualApproved': serializer.toJson<int>( 'userDiscoveryManualApproved': serializer.toJson<int?>(
userDiscoveryManualApproved, userDiscoveryManualApproved,
), ),
'mediaSendCounter': serializer.toJson<int>(mediaSendCounter), 'mediaSendCounter': serializer.toJson<int>(mediaSendCounter),
@ -485,7 +490,7 @@ class ContactsData extends DataClass implements Insertable<ContactsData> {
int? createdAt, int? createdAt,
Value<i2.Uint8List?> userDiscoveryVersion = const Value.absent(), Value<i2.Uint8List?> userDiscoveryVersion = const Value.absent(),
int? userDiscoveryExcluded, int? userDiscoveryExcluded,
int? userDiscoveryManualApproved, Value<int?> userDiscoveryManualApproved = const Value.absent(),
int? mediaSendCounter, int? mediaSendCounter,
int? mediaReceivedCounter, int? mediaReceivedCounter,
}) => ContactsData( }) => ContactsData(
@ -508,8 +513,9 @@ class ContactsData extends DataClass implements Insertable<ContactsData> {
? userDiscoveryVersion.value ? userDiscoveryVersion.value
: this.userDiscoveryVersion, : this.userDiscoveryVersion,
userDiscoveryExcluded: userDiscoveryExcluded ?? this.userDiscoveryExcluded, userDiscoveryExcluded: userDiscoveryExcluded ?? this.userDiscoveryExcluded,
userDiscoveryManualApproved: userDiscoveryManualApproved: userDiscoveryManualApproved.present
userDiscoveryManualApproved ?? this.userDiscoveryManualApproved, ? userDiscoveryManualApproved.value
: this.userDiscoveryManualApproved,
mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter, mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter,
mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter, mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter,
); );
@ -649,7 +655,7 @@ class ContactsCompanion extends UpdateCompanion<ContactsData> {
final Value<int> createdAt; final Value<int> createdAt;
final Value<i2.Uint8List?> userDiscoveryVersion; final Value<i2.Uint8List?> userDiscoveryVersion;
final Value<int> userDiscoveryExcluded; final Value<int> userDiscoveryExcluded;
final Value<int> userDiscoveryManualApproved; final Value<int?> userDiscoveryManualApproved;
final Value<int> mediaSendCounter; final Value<int> mediaSendCounter;
final Value<int> mediaReceivedCounter; final Value<int> mediaReceivedCounter;
const ContactsCompanion({ const ContactsCompanion({
@ -756,7 +762,7 @@ class ContactsCompanion extends UpdateCompanion<ContactsData> {
Value<int>? createdAt, Value<int>? createdAt,
Value<i2.Uint8List?>? userDiscoveryVersion, Value<i2.Uint8List?>? userDiscoveryVersion,
Value<int>? userDiscoveryExcluded, Value<int>? userDiscoveryExcluded,
Value<int>? userDiscoveryManualApproved, Value<int?>? userDiscoveryManualApproved,
Value<int>? mediaSendCounter, Value<int>? mediaSendCounter,
Value<int>? mediaReceivedCounter, Value<int>? mediaReceivedCounter,
}) { }) {