From ced68167d18c6db473a0835f02cb11d43360fb65 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 5 Oct 2026 22:45:05 +0200 Subject: [PATCH] add joined chip to contact view --- CHANGELOG.md | 12 + lib/core/bridge/api.dart | 13 +- lib/core/frb_generated.dart | 252 ++++++++------ lib/core/user_config.dart | 6 + lib/main.dart | 4 + lib/src/database/tables/contacts.table.dart | 1 + lib/src/database/twonly.db.g.dart | 68 ++++ .../generated/app_localizations.dart | 114 +++++++ .../generated/app_localizations_ar.dart | 95 ++++++ .../generated/app_localizations_de.dart | 87 +++++ .../generated/app_localizations_en.dart | 87 +++++ lib/src/localization/translations/ar.arb | 21 +- lib/src/localization/translations/de.arb | 21 +- lib/src/localization/translations/en.arb | 21 +- lib/src/services/api/api.service.dart | 4 + lib/src/utils/misc.dart | 16 + .../visual/components/release_notes.comp.dart | 312 ++++++++++++++++++ .../release_notes/release_notes_0_6_0.dart | 43 +++ .../entries/chat_ask_a_friend.entry.dart | 8 + .../entries/chat_contacts.entry.dart | 8 + .../views/contact/add_new_contact.view.dart | 8 + .../friend_suggestions.comp.dart | 8 + .../visual/views/contact/contact.view.dart | 78 +++-- lib/src/visual/views/home.view.dart | 41 ++- ...fb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json | 12 - ...779b5fe4f003a16ba01ee2834f028fabf213f.json | 12 + rust/src/api/messages/incoming/contact.rs | 7 + .../api/websocket/server_to_client.proto | 1 + rust/src/api/server/contacts.rs | 33 ++ rust/src/bridge/api.rs | 7 + .../app/migrations/0030_contact_joined_at.sql | 3 + rust/src/database/app/mod.rs | 30 +- rust/src/database/app/tables/contact.rs | 1 + rust/src/frb_generated.rs | 223 ++++++++----- rust/src/services/contacts.rs | 15 +- rust/src/user_config.rs | 5 + .../visual/components/release_notes_test.dart | 57 ++++ test/visual/contact_joined_at_test.dart | 48 +++ 38 files changed, 1539 insertions(+), 243 deletions(-) create mode 100644 lib/src/visual/components/release_notes.comp.dart create mode 100644 lib/src/visual/release_notes/release_notes_0_6_0.dart delete mode 100644 rust/.sqlx/query-7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json create mode 100644 rust/src/database/app/migrations/0030_contact_joined_at.sql create mode 100644 test/visual/components/release_notes_test.dart create mode 100644 test/visual/contact_joined_at_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 075cee1a..b1fd25ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.6.0 + +- New: Stickers +- New: Home screen widgets +- New: Stories +- New: Mini games +- New: Custom avatars +- New: Arabic language (Experimental) +- Improve: Message delivery and push notification reliability +- Improve: Many smaller new changes +- Fix: Many small issues + ## 0.5.4 - Fix: Recover broken sessions more reliably diff --git a/lib/core/bridge/api.dart b/lib/core/bridge/api.dart index ccde0ea6..17f15ad1 100644 --- a/lib/core/bridge/api.dart +++ b/lib/core/bridge/api.dart @@ -245,16 +245,21 @@ class FrbUserData { final PlatformInt64 userId; final Uint8List username; final Uint8List publicIdentityKey; + final PlatformInt64? joinedAt; const FrbUserData({ required this.userId, required this.username, required this.publicIdentityKey, + this.joinedAt, }); @override int get hashCode => - userId.hashCode ^ username.hashCode ^ publicIdentityKey.hashCode; + userId.hashCode ^ + username.hashCode ^ + publicIdentityKey.hashCode ^ + joinedAt.hashCode; @override bool operator ==(Object other) => @@ -263,7 +268,8 @@ class FrbUserData { runtimeType == other.runtimeType && userId == other.userId && username == other.username && - publicIdentityKey == other.publicIdentityKey; + publicIdentityKey == other.publicIdentityKey && + joinedAt == other.joinedAt; } /// Flutter-facing facade for the Rust-owned API runtime. @@ -597,6 +603,9 @@ class RustApi { static Future purgeWidgetMedia() => RustLib.instance.api.crateBridgeApiRustApiPurgeWidgetMedia(); + static Future refreshContactJoinedDates() => + RustLib.instance.api.crateBridgeApiRustApiRefreshContactJoinedDates(); + /// Republishes the widget manifest. /// /// The contact groups a widget offers in its configuration UI are read from diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index 58ee8023..0f048d8a 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -89,7 +89,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1844664049; + int get rustContentHash => -1901082359; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -417,6 +417,8 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeApiRustApiPurgeWidgetMedia(); + Future crateBridgeApiRustApiRefreshContactJoinedDates(); + Future crateBridgeApiRustApiRefreshWidgetManifest(); Future crateBridgeApiRustApiRegister({ @@ -3891,7 +3893,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateBridgeApiRustApiRefreshWidgetManifest() { + Future crateBridgeApiRustApiRefreshContactJoinedDates() { return handler.executeNormal( NormalTask( callFfi: (port_) { @@ -3907,6 +3909,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeSuccessData: sse_decode_unit, decodeErrorData: sse_decode_AnyhowException, ), + constMeta: kCrateBridgeApiRustApiRefreshContactJoinedDatesConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiRefreshContactJoinedDatesConstMeta => + const TaskConstMeta( + debugName: "rust_api_refresh_contact_joined_dates", + argNames: [], + ); + + @override + Future crateBridgeApiRustApiRefreshWidgetManifest() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 95, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), constMeta: kCrateBridgeApiRustApiRefreshWidgetManifestConstMeta, argValues: [], apiImpl: this, @@ -3938,7 +3970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 95, + funcId: 96, port: port_, ); }, @@ -3973,7 +4005,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 96, + funcId: 97, port: port_, ); }, @@ -4012,7 +4044,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 97, + funcId: 98, port: port_, ); }, @@ -4054,7 +4086,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 98, + funcId: 99, port: port_, ); }, @@ -4085,7 +4117,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 99, + funcId: 100, port: port_, ); }, @@ -4118,7 +4150,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 100, + funcId: 101, port: port_, ); }, @@ -4151,7 +4183,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 101, + funcId: 102, port: port_, ); }, @@ -4186,7 +4218,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 102, + funcId: 103, port: port_, ); }, @@ -4219,7 +4251,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 103, + funcId: 104, port: port_, ); }, @@ -4252,7 +4284,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 104, + funcId: 105, port: port_, ); }, @@ -4285,7 +4317,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 105, + funcId: 106, port: port_, ); }, @@ -4322,7 +4354,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 106, + funcId: 107, port: port_, ); }, @@ -4352,7 +4384,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 107, + funcId: 108, port: port_, ); }, @@ -4382,7 +4414,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 108, + funcId: 109, port: port_, ); }, @@ -4412,7 +4444,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 109, + funcId: 110, port: port_, ); }, @@ -4445,7 +4477,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 110, + funcId: 111, port: port_, ); }, @@ -4476,7 +4508,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 111, + funcId: 112, port: port_, ); }, @@ -4509,7 +4541,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 112, + funcId: 113, port: port_, ); }, @@ -4555,7 +4587,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 113, + funcId: 114, port: port_, ); }, @@ -4611,7 +4643,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 114, + funcId: 115, port: port_, ); }, @@ -4661,7 +4693,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 115, + funcId: 116, port: port_, ); }, @@ -4706,7 +4738,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 116, + funcId: 117, port: port_, ); }, @@ -4741,7 +4773,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 117, + funcId: 118, port: port_, ); }, @@ -4776,7 +4808,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 118, + funcId: 119, port: port_, ); }, @@ -4809,7 +4841,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 119, + funcId: 120, port: port_, ); }, @@ -4846,7 +4878,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 120, + funcId: 121, port: port_, ); }, @@ -4877,7 +4909,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 121, + funcId: 122, port: port_, ); }, @@ -4915,7 +4947,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 122, + funcId: 123, port: port_, ); }, @@ -4950,7 +4982,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 123, + funcId: 124, port: port_, ); }, @@ -4989,7 +5021,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 124, + funcId: 125, port: port_, ); }, @@ -5022,7 +5054,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 125, + funcId: 126, port: port_, ); }, @@ -5053,7 +5085,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 126, + funcId: 127, port: port_, ); }, @@ -5088,7 +5120,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 127, + funcId: 128, port: port_, ); }, @@ -5118,7 +5150,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 128, + funcId: 129, port: port_, ); }, @@ -5151,7 +5183,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 129, + funcId: 130, port: port_, ); }, @@ -5186,7 +5218,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 130, + funcId: 131, port: port_, ); }, @@ -5219,7 +5251,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 131, + funcId: 132, port: port_, ); }, @@ -5250,7 +5282,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 132, + funcId: 133, port: port_, ); }, @@ -5287,7 +5319,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 133, + funcId: 134, port: port_, ); }, @@ -5339,7 +5371,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 134, + funcId: 135, port: port_, ); }, @@ -5392,7 +5424,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 135, + funcId: 136, port: port_, ); }, @@ -5432,7 +5464,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 136, + funcId: 137, port: port_, ); }, @@ -5465,7 +5497,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 137, + funcId: 138, port: port_, ); }, @@ -5498,7 +5530,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 138, + funcId: 139, port: port_, ); }, @@ -5535,7 +5567,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 139, + funcId: 140, port: port_, ); }, @@ -5567,7 +5599,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 140, + funcId: 141, port: port_, ); }, @@ -5600,7 +5632,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 141, + funcId: 142, port: port_, ); }, @@ -5635,7 +5667,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 142, + funcId: 143, port: port_, ); }, @@ -5667,7 +5699,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 143, + funcId: 144, port: port_, ); }, @@ -5705,7 +5737,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 144, + funcId: 145, port: port_, ); }, @@ -5738,7 +5770,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 145, + funcId: 146, port: port_, ); }, @@ -5776,7 +5808,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 146, + funcId: 147, port: port_, ); }, @@ -5813,7 +5845,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 147, + funcId: 148, port: port_, ); }, @@ -5850,7 +5882,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 148, + funcId: 149, port: port_, ); }, @@ -5888,7 +5920,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 149, + funcId: 150, port: port_, ); }, @@ -5926,7 +5958,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 150, + funcId: 151, port: port_, ); }, @@ -5959,7 +5991,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 151, + funcId: 152, port: port_, ); }, @@ -5991,7 +6023,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 152, + funcId: 153, port: port_, ); }, @@ -6026,7 +6058,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 153, + funcId: 154, port: port_, ); }, @@ -6063,7 +6095,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 154, + funcId: 155, port: port_, ); }, @@ -6096,7 +6128,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 155, + funcId: 156, port: port_, ); }, @@ -6128,7 +6160,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 156, + funcId: 157, port: port_, ); }, @@ -6163,7 +6195,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 157, + funcId: 158, port: port_, ); }, @@ -6202,7 +6234,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 158, + funcId: 159, port: port_, ); }, @@ -6239,7 +6271,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 159, + funcId: 160, port: port_, ); }, @@ -6269,7 +6301,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 160, + funcId: 161, port: port_, ); }, @@ -6301,7 +6333,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 161, + funcId: 162, port: port_, ); }, @@ -6336,7 +6368,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 162, + funcId: 163, port: port_, ); }, @@ -6371,7 +6403,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 163, + funcId: 164, port: port_, ); }, @@ -6402,7 +6434,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 164, + funcId: 165, port: port_, ); }, @@ -6440,7 +6472,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 165, + funcId: 166, port: port_, ); }, @@ -6475,7 +6507,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 166, + funcId: 167, port: port_, ); }, @@ -6514,7 +6546,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 167, + funcId: 168, port: port_, ); }, @@ -6549,7 +6581,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 168, + funcId: 169, port: port_, ); }, @@ -6594,7 +6626,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 169, + funcId: 170, port: port_, ); }, @@ -6637,7 +6669,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 170, + funcId: 171, port: port_, ); }, @@ -6669,7 +6701,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 171, + funcId: 172, port: port_, ); }, @@ -6704,7 +6736,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 172, + funcId: 173, port: port_, ); }, @@ -6739,7 +6771,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 173, + funcId: 174, port: port_, ); }, @@ -6774,7 +6806,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 174, + funcId: 175, port: port_, ); }, @@ -6807,7 +6839,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 175, + funcId: 176, )!; }, codec: SseCodec( @@ -6847,7 +6879,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 176, + funcId: 177, port: port_, ); }, @@ -6892,7 +6924,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 177, + funcId: 178, port: port_, ); }, @@ -6922,7 +6954,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 178, + funcId: 179, port: port_, ); }, @@ -6955,7 +6987,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 179, + funcId: 180, port: port_, ); }, @@ -6990,7 +7022,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 180, + funcId: 181, port: port_, ); }, @@ -7029,7 +7061,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 181, + funcId: 182, )!; }, codec: SseCodec( @@ -7498,12 +7530,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { FrbUserData dco_decode_frb_user_data(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 3) - throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); return FrbUserData( userId: dco_decode_i_64(arr[0]), username: dco_decode_list_prim_u_8_strict(arr[1]), publicIdentityKey: dco_decode_list_prim_u_8_strict(arr[2]), + joinedAt: dco_decode_opt_box_autoadd_i_64(arr[3]), ); } @@ -8128,8 +8161,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { UserConfig dco_decode_user_config(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 65) - throw Exception('unexpected arr length: expect 65 but see ${arr.length}'); + if (arr.length != 66) + throw Exception('unexpected arr length: expect 66 but see ${arr.length}'); return UserConfig( userId: dco_decode_i_64(arr[0]), username: dco_decode_String(arr[1]), @@ -8187,20 +8220,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { currentSignedPreKeyIndexStart: dco_decode_i_64(arr[49]), lastChangeLogHash: dco_decode_opt_list_prim_u_8_strict(arr[50]), hideChangeLog: dco_decode_bool(arr[51]), - hideMemoriesBackupPromo: dco_decode_bool(arr[52]), - hideWidgetShareExplainer: dco_decode_bool(arr[53]), - updateFcmToken: dco_decode_bool(arr[54]), - canUseLoginTokenForAuth: dco_decode_bool(arr[55]), - twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[56]), - isBackupEnabled: dco_decode_bool(arr[57]), + lastReleaseNotesVersion: dco_decode_opt_String(arr[52]), + hideMemoriesBackupPromo: dco_decode_bool(arr[53]), + hideWidgetShareExplainer: dco_decode_bool(arr[54]), + updateFcmToken: dco_decode_bool(arr[55]), + canUseLoginTokenForAuth: dco_decode_bool(arr[56]), + twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[57]), + isBackupEnabled: dco_decode_bool(arr[58]), passwordLessRecovery: - dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[58]), - fcmToken: dco_decode_opt_String(arr[59]), - lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[60]), - lastServerMessageAt: dco_decode_opt_box_autoadd_i_64(arr[61]), - currentSetupPage: dco_decode_opt_String(arr[62]), - skipSetupPages: dco_decode_bool(arr[63]), - hasZoomed: dco_decode_bool(arr[64]), + dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[59]), + fcmToken: dco_decode_opt_String(arr[60]), + lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[61]), + lastServerMessageAt: dco_decode_opt_box_autoadd_i_64(arr[62]), + currentSetupPage: dco_decode_opt_String(arr[63]), + skipSetupPages: dco_decode_bool(arr[64]), + hasZoomed: dco_decode_bool(arr[65]), ); } @@ -8709,10 +8743,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_userId = sse_decode_i_64(deserializer); var var_username = sse_decode_list_prim_u_8_strict(deserializer); var var_publicIdentityKey = sse_decode_list_prim_u_8_strict(deserializer); + var var_joinedAt = sse_decode_opt_box_autoadd_i_64(deserializer); return FrbUserData( userId: var_userId, username: var_username, publicIdentityKey: var_publicIdentityKey, + joinedAt: var_joinedAt, ); } @@ -9566,6 +9602,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { deserializer, ); var var_hideChangeLog = sse_decode_bool(deserializer); + var var_lastReleaseNotesVersion = sse_decode_opt_String(deserializer); var var_hideMemoriesBackupPromo = sse_decode_bool(deserializer); var var_hideWidgetShareExplainer = sse_decode_bool(deserializer); var var_updateFcmToken = sse_decode_bool(deserializer); @@ -9638,6 +9675,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { currentSignedPreKeyIndexStart: var_currentSignedPreKeyIndexStart, lastChangeLogHash: var_lastChangeLogHash, hideChangeLog: var_hideChangeLog, + lastReleaseNotesVersion: var_lastReleaseNotesVersion, hideMemoriesBackupPromo: var_hideMemoriesBackupPromo, hideWidgetShareExplainer: var_hideWidgetShareExplainer, updateFcmToken: var_updateFcmToken, @@ -10209,6 +10247,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_64(self.userId, serializer); sse_encode_list_prim_u_8_strict(self.username, serializer); sse_encode_list_prim_u_8_strict(self.publicIdentityKey, serializer); + sse_encode_opt_box_autoadd_i_64(self.joinedAt, serializer); } @protected @@ -10984,6 +11023,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_64(self.currentSignedPreKeyIndexStart, serializer); sse_encode_opt_list_prim_u_8_strict(self.lastChangeLogHash, serializer); sse_encode_bool(self.hideChangeLog, serializer); + sse_encode_opt_String(self.lastReleaseNotesVersion, serializer); sse_encode_bool(self.hideMemoriesBackupPromo, serializer); sse_encode_bool(self.hideWidgetShareExplainer, serializer); sse_encode_bool(self.updateFcmToken, serializer); diff --git a/lib/core/user_config.dart b/lib/core/user_config.dart index af31267d..8b0a6234 100644 --- a/lib/core/user_config.dart +++ b/lib/core/user_config.dart @@ -175,6 +175,9 @@ class UserConfig { PlatformInt64 currentSignedPreKeyIndexStart; Uint8List? lastChangeLogHash; bool hideChangeLog; + + /// Last in-app release note the user dismissed. + String? lastReleaseNotesVersion; bool hideMemoriesBackupPromo; bool hideWidgetShareExplainer; bool updateFcmToken; @@ -250,6 +253,7 @@ class UserConfig { required this.currentSignedPreKeyIndexStart, this.lastChangeLogHash, required this.hideChangeLog, + this.lastReleaseNotesVersion, required this.hideMemoriesBackupPromo, required this.hideWidgetShareExplainer, required this.updateFcmToken, @@ -319,6 +323,7 @@ class UserConfig { currentSignedPreKeyIndexStart.hashCode ^ lastChangeLogHash.hashCode ^ hideChangeLog.hashCode ^ + lastReleaseNotesVersion.hashCode ^ hideMemoriesBackupPromo.hashCode ^ hideWidgetShareExplainer.hashCode ^ updateFcmToken.hashCode ^ @@ -397,6 +402,7 @@ class UserConfig { other.currentSignedPreKeyIndexStart && lastChangeLogHash == other.lastChangeLogHash && hideChangeLog == other.hideChangeLog && + lastReleaseNotesVersion == other.lastReleaseNotesVersion && hideMemoriesBackupPromo == other.hideMemoriesBackupPromo && hideWidgetShareExplainer == other.hideWidgetShareExplainer && updateFcmToken == other.updateFcmToken && diff --git a/lib/main.dart b/lib/main.dart index 75d10356..93922a0a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -310,6 +310,10 @@ Future postStartupTasks() async { RustApi.finishStartedMediaUploads(), 'finishStartedMediaUploads', ); + unawaitedRustCall( + RustApi.refreshContactJoinedDates(), + 'refreshContactJoinedDates', + ); unawaited( newsService.init().then((_) { final lastDownload = newsService.lastDownloadedAt; diff --git a/lib/src/database/tables/contacts.table.dart b/lib/src/database/tables/contacts.table.dart index a3ef9c51..eec59e12 100644 --- a/lib/src/database/tables/contacts.table.dart +++ b/lib/src/database/tables/contacts.table.dart @@ -54,6 +54,7 @@ class Contacts extends Table { boolean().withDefault(const Constant(false))(); IntColumn get twonlyScore => integer().nullable()(); + DateTimeColumn get joinedAt => dateTime().nullable()(); IntColumn get mediaSendCounter => integer().withDefault(const Constant(0))(); IntColumn get mediaReceivedCounter => diff --git a/lib/src/database/twonly.db.g.dart b/lib/src/database/twonly.db.g.dart index c0f0da24..72f04aae 100644 --- a/lib/src/database/twonly.db.g.dart +++ b/lib/src/database/twonly.db.g.dart @@ -348,6 +348,17 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> { type: DriftSqlType.int, requiredDuringInsert: false, ); + static const VerificationMeta _joinedAtMeta = const VerificationMeta( + 'joinedAt', + ); + @override + late final GeneratedColumn joinedAt = GeneratedColumn( + 'joined_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); static const VerificationMeta _mediaSendCounterMeta = const VerificationMeta( 'mediaSendCounter', ); @@ -400,6 +411,7 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> { widgetSharingAllowed, widgetSharingGranted, twonlyScore, + joinedAt, mediaSendCounter, mediaReceivedCounter, ]; @@ -627,6 +639,12 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> { ), ); } + if (data.containsKey('joined_at')) { + context.handle( + _joinedAtMeta, + joinedAt.isAcceptableOrUnknown(data['joined_at']!, _joinedAtMeta), + ); + } if (data.containsKey('media_send_counter')) { context.handle( _mediaSendCounterMeta, @@ -764,6 +782,10 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> { DriftSqlType.int, data['${effectivePrefix}twonly_score'], ), + joinedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}joined_at'], + ), mediaSendCounter: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}media_send_counter'], @@ -814,6 +836,7 @@ class Contact extends DataClass implements Insertable { final bool widgetSharingAllowed; final bool widgetSharingGranted; final int? twonlyScore; + final DateTime? joinedAt; final int mediaSendCounter; final int mediaReceivedCounter; const Contact({ @@ -844,6 +867,7 @@ class Contact extends DataClass implements Insertable { required this.widgetSharingAllowed, required this.widgetSharingGranted, this.twonlyScore, + this.joinedAt, required this.mediaSendCounter, required this.mediaReceivedCounter, }); @@ -915,6 +939,9 @@ class Contact extends DataClass implements Insertable { if (!nullToAbsent || twonlyScore != null) { map['twonly_score'] = Variable(twonlyScore); } + if (!nullToAbsent || joinedAt != null) { + map['joined_at'] = Variable(joinedAt); + } map['media_send_counter'] = Variable(mediaSendCounter); map['media_received_counter'] = Variable(mediaReceivedCounter); return map; @@ -977,6 +1004,9 @@ class Contact extends DataClass implements Insertable { twonlyScore: twonlyScore == null && nullToAbsent ? const Value.absent() : Value(twonlyScore), + joinedAt: joinedAt == null && nullToAbsent + ? const Value.absent() + : Value(joinedAt), mediaSendCounter: Value(mediaSendCounter), mediaReceivedCounter: Value(mediaReceivedCounter), ); @@ -1045,6 +1075,7 @@ class Contact extends DataClass implements Insertable { json['widgetSharingGranted'], ), twonlyScore: serializer.fromJson(json['twonlyScore']), + joinedAt: serializer.fromJson(json['joinedAt']), mediaSendCounter: serializer.fromJson(json['mediaSendCounter']), mediaReceivedCounter: serializer.fromJson( json['mediaReceivedCounter'], @@ -1100,6 +1131,7 @@ class Contact extends DataClass implements Insertable { 'widgetSharingAllowed': serializer.toJson(widgetSharingAllowed), 'widgetSharingGranted': serializer.toJson(widgetSharingGranted), 'twonlyScore': serializer.toJson(twonlyScore), + 'joinedAt': serializer.toJson(joinedAt), 'mediaSendCounter': serializer.toJson(mediaSendCounter), 'mediaReceivedCounter': serializer.toJson(mediaReceivedCounter), }; @@ -1133,6 +1165,7 @@ class Contact extends DataClass implements Insertable { bool? widgetSharingAllowed, bool? widgetSharingGranted, Value twonlyScore = const Value.absent(), + Value joinedAt = const Value.absent(), int? mediaSendCounter, int? mediaReceivedCounter, }) => Contact( @@ -1182,6 +1215,7 @@ class Contact extends DataClass implements Insertable { widgetSharingAllowed: widgetSharingAllowed ?? this.widgetSharingAllowed, widgetSharingGranted: widgetSharingGranted ?? this.widgetSharingGranted, twonlyScore: twonlyScore.present ? twonlyScore.value : this.twonlyScore, + joinedAt: joinedAt.present ? joinedAt.value : this.joinedAt, mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter, mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter, ); @@ -1252,6 +1286,7 @@ class Contact extends DataClass implements Insertable { twonlyScore: data.twonlyScore.present ? data.twonlyScore.value : this.twonlyScore, + joinedAt: data.joinedAt.present ? data.joinedAt.value : this.joinedAt, mediaSendCounter: data.mediaSendCounter.present ? data.mediaSendCounter.value : this.mediaSendCounter, @@ -1293,6 +1328,7 @@ class Contact extends DataClass implements Insertable { ..write('widgetSharingAllowed: $widgetSharingAllowed, ') ..write('widgetSharingGranted: $widgetSharingGranted, ') ..write('twonlyScore: $twonlyScore, ') + ..write('joinedAt: $joinedAt, ') ..write('mediaSendCounter: $mediaSendCounter, ') ..write('mediaReceivedCounter: $mediaReceivedCounter') ..write(')')) @@ -1328,6 +1364,7 @@ class Contact extends DataClass implements Insertable { widgetSharingAllowed, widgetSharingGranted, twonlyScore, + joinedAt, mediaSendCounter, mediaReceivedCounter, ]); @@ -1376,6 +1413,7 @@ class Contact extends DataClass implements Insertable { other.widgetSharingAllowed == this.widgetSharingAllowed && other.widgetSharingGranted == this.widgetSharingGranted && other.twonlyScore == this.twonlyScore && + other.joinedAt == this.joinedAt && other.mediaSendCounter == this.mediaSendCounter && other.mediaReceivedCounter == this.mediaReceivedCounter); } @@ -1408,6 +1446,7 @@ class ContactsCompanion extends UpdateCompanion { final Value widgetSharingAllowed; final Value widgetSharingGranted; final Value twonlyScore; + final Value joinedAt; final Value mediaSendCounter; final Value mediaReceivedCounter; const ContactsCompanion({ @@ -1438,6 +1477,7 @@ class ContactsCompanion extends UpdateCompanion { this.widgetSharingAllowed = const Value.absent(), this.widgetSharingGranted = const Value.absent(), this.twonlyScore = const Value.absent(), + this.joinedAt = const Value.absent(), this.mediaSendCounter = const Value.absent(), this.mediaReceivedCounter = const Value.absent(), }); @@ -1469,6 +1509,7 @@ class ContactsCompanion extends UpdateCompanion { this.widgetSharingAllowed = const Value.absent(), this.widgetSharingGranted = const Value.absent(), this.twonlyScore = const Value.absent(), + this.joinedAt = const Value.absent(), this.mediaSendCounter = const Value.absent(), this.mediaReceivedCounter = const Value.absent(), }) : username = Value(username); @@ -1500,6 +1541,7 @@ class ContactsCompanion extends UpdateCompanion { Expression? widgetSharingAllowed, Expression? widgetSharingGranted, Expression? twonlyScore, + Expression? joinedAt, Expression? mediaSendCounter, Expression? mediaReceivedCounter, }) { @@ -1545,6 +1587,7 @@ class ContactsCompanion extends UpdateCompanion { if (widgetSharingGranted != null) 'widget_sharing_granted': widgetSharingGranted, if (twonlyScore != null) 'twonly_score': twonlyScore, + if (joinedAt != null) 'joined_at': joinedAt, if (mediaSendCounter != null) 'media_send_counter': mediaSendCounter, if (mediaReceivedCounter != null) 'media_received_counter': mediaReceivedCounter, @@ -1579,6 +1622,7 @@ class ContactsCompanion extends UpdateCompanion { Value? widgetSharingAllowed, Value? widgetSharingGranted, Value? twonlyScore, + Value? joinedAt, Value? mediaSendCounter, Value? mediaReceivedCounter, }) { @@ -1618,6 +1662,7 @@ class ContactsCompanion extends UpdateCompanion { widgetSharingAllowed: widgetSharingAllowed ?? this.widgetSharingAllowed, widgetSharingGranted: widgetSharingGranted ?? this.widgetSharingGranted, twonlyScore: twonlyScore ?? this.twonlyScore, + joinedAt: joinedAt ?? this.joinedAt, mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter, mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter, ); @@ -1735,6 +1780,9 @@ class ContactsCompanion extends UpdateCompanion { if (twonlyScore.present) { map['twonly_score'] = Variable(twonlyScore.value); } + if (joinedAt.present) { + map['joined_at'] = Variable(joinedAt.value); + } if (mediaSendCounter.present) { map['media_send_counter'] = Variable(mediaSendCounter.value); } @@ -1776,6 +1824,7 @@ class ContactsCompanion extends UpdateCompanion { ..write('widgetSharingAllowed: $widgetSharingAllowed, ') ..write('widgetSharingGranted: $widgetSharingGranted, ') ..write('twonlyScore: $twonlyScore, ') + ..write('joinedAt: $joinedAt, ') ..write('mediaSendCounter: $mediaSendCounter, ') ..write('mediaReceivedCounter: $mediaReceivedCounter') ..write(')')) @@ -15812,6 +15861,7 @@ typedef $$ContactsTableCreateCompanionBuilder = Value widgetSharingAllowed, Value widgetSharingGranted, Value twonlyScore, + Value joinedAt, Value mediaSendCounter, Value mediaReceivedCounter, }); @@ -15844,6 +15894,7 @@ typedef $$ContactsTableUpdateCompanionBuilder = Value widgetSharingAllowed, Value widgetSharingGranted, Value twonlyScore, + Value joinedAt, Value mediaSendCounter, Value mediaReceivedCounter, }); @@ -16268,6 +16319,11 @@ class $$ContactsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get joinedAt => $composableBuilder( + column: $table.joinedAt, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get mediaSendCounter => $composableBuilder( column: $table.mediaSendCounter, builder: (column) => ColumnFilters(column), @@ -16733,6 +16789,11 @@ class $$ContactsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get joinedAt => $composableBuilder( + column: $table.joinedAt, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get mediaSendCounter => $composableBuilder( column: $table.mediaSendCounter, builder: (column) => ColumnOrderings(column), @@ -16875,6 +16936,9 @@ class $$ContactsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get joinedAt => + $composableBuilder(column: $table.joinedAt, builder: (column) => column); + GeneratedColumn get mediaSendCounter => $composableBuilder( column: $table.mediaSendCounter, builder: (column) => column, @@ -17270,6 +17334,7 @@ class $$ContactsTableTableManager Value widgetSharingAllowed = const Value.absent(), Value widgetSharingGranted = const Value.absent(), Value twonlyScore = const Value.absent(), + Value joinedAt = const Value.absent(), Value mediaSendCounter = const Value.absent(), Value mediaReceivedCounter = const Value.absent(), }) => ContactsCompanion( @@ -17300,6 +17365,7 @@ class $$ContactsTableTableManager widgetSharingAllowed: widgetSharingAllowed, widgetSharingGranted: widgetSharingGranted, twonlyScore: twonlyScore, + joinedAt: joinedAt, mediaSendCounter: mediaSendCounter, mediaReceivedCounter: mediaReceivedCounter, ), @@ -17334,6 +17400,7 @@ class $$ContactsTableTableManager Value widgetSharingAllowed = const Value.absent(), Value widgetSharingGranted = const Value.absent(), Value twonlyScore = const Value.absent(), + Value joinedAt = const Value.absent(), Value mediaSendCounter = const Value.absent(), Value mediaReceivedCounter = const Value.absent(), }) => ContactsCompanion.insert( @@ -17364,6 +17431,7 @@ class $$ContactsTableTableManager widgetSharingAllowed: widgetSharingAllowed, widgetSharingGranted: widgetSharingGranted, twonlyScore: twonlyScore, + joinedAt: joinedAt, mediaSendCounter: mediaSendCounter, mediaReceivedCounter: mediaReceivedCounter, ), diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 04c01584..0d3fbcdc 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -682,6 +682,30 @@ abstract class AppLocalizations { /// **'The twonly Score reflects a person\'s activity on twonly. It is calculated from several factors, such as how many photos and videos they send and receive.'** String get contactTwonlyScoreDescription; + /// No description provided for @contactJoinedToday. + /// + /// In en, this message translates to: + /// **'Joined today'** + String get contactJoinedToday; + + /// No description provided for @contactJoinedDaysAgo. + /// + /// In en, this message translates to: + /// **'{days, plural, =1{Joined 1 day ago} other{Joined {days} days ago}}'** + String contactJoinedDaysAgo(num days); + + /// No description provided for @contactJoinedMonthsAgo. + /// + /// In en, this message translates to: + /// **'{months, plural, =1{Joined 1 month ago} other{Joined {months} months ago}}'** + String contactJoinedMonthsAgo(num months); + + /// No description provided for @contactJoinedYearsAgo. + /// + /// In en, this message translates to: + /// **'{years, plural, =1{Joined 1 year ago} other{Joined {years} years ago}}'** + String contactJoinedYearsAgo(num years); + /// No description provided for @privacyVisibilityNobody. /// /// In en, this message translates to: @@ -5169,6 +5193,96 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Delete sticker'** String get deleteSticker; + + /// No description provided for @releaseNotesTitle. + /// + /// In en, this message translates to: + /// **'What\'s new'** + String get releaseNotesTitle; + + /// No description provided for @releaseNotesIntroduction. + /// + /// In en, this message translates to: + /// **'The biggest twonly update yet is here. Here\'s a quick look at what\'s new.'** + String get releaseNotesIntroduction; + + /// No description provided for @releaseNotesAlsoImproved. + /// + /// In en, this message translates to: + /// **'Also improved'** + String get releaseNotesAlsoImproved; + + /// No description provided for @releaseNotesStickers. + /// + /// In en, this message translates to: + /// **'Stickers'** + String get releaseNotesStickers; + + /// No description provided for @releaseNotesStickersDescription. + /// + /// In en, this message translates to: + /// **'Turn your photos into stickers and share them in chats.'** + String get releaseNotesStickersDescription; + + /// No description provided for @releaseNotesWidgets. + /// + /// In en, this message translates to: + /// **'Widgets'** + String get releaseNotesWidgets; + + /// No description provided for @releaseNotesWidgetsDescription. + /// + /// In en, this message translates to: + /// **'See fresh photos from your favorite people right on your home screen.'** + String get releaseNotesWidgetsDescription; + + /// No description provided for @releaseNotesStories. + /// + /// In en, this message translates to: + /// **'Stories'** + String get releaseNotesStories; + + /// No description provided for @releaseNotesStoriesDescription. + /// + /// In en, this message translates to: + /// **'Share moments with your contacts for 24 hours.'** + String get releaseNotesStoriesDescription; + + /// No description provided for @releaseNotesMiniGames. + /// + /// In en, this message translates to: + /// **'Mini games in chats & groups'** + String get releaseNotesMiniGames; + + /// No description provided for @releaseNotesMiniGamesDescription. + /// + /// In en, this message translates to: + /// **'Play small games together, right inside your conversations.'** + String get releaseNotesMiniGamesDescription; + + /// No description provided for @releaseNotesReliableMessages. + /// + /// In en, this message translates to: + /// **'Better message and push reliability'** + String get releaseNotesReliableMessages; + + /// No description provided for @releaseNotesImprovedUi. + /// + /// In en, this message translates to: + /// **'Improved interface'** + String get releaseNotesImprovedUi; + + /// No description provided for @releaseNotesCustomAvatars. + /// + /// In en, this message translates to: + /// **'Custom avatars'** + String get releaseNotesCustomAvatars; + + /// No description provided for @releaseNotesArabic. + /// + /// In en, this message translates to: + /// **'Arabic language (experimental)'** + String get releaseNotesArabic; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_ar.dart b/lib/src/localization/generated/app_localizations_ar.dart index b874f888..a7d806ae 100644 --- a/lib/src/localization/generated/app_localizations_ar.dart +++ b/lib/src/localization/generated/app_localizations_ar.dart @@ -316,6 +316,51 @@ class AppLocalizationsAr extends AppLocalizations { String get contactTwonlyScoreDescription => 'تعكس نقاط twonly نشاط الشخص على twonly. ويتم احتسابها استنادًا إلى عدة عوامل، مثل عدد الصور ومقاطع الفيديو التي يرسلها ويستلمها.'; + @override + String get contactJoinedToday => 'انضم اليوم'; + + @override + String contactJoinedDaysAgo(num days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: 'انضم منذ $days يوم', + many: 'انضم منذ $days يومًا', + few: 'انضم منذ $days أيام', + two: 'انضم منذ يومين', + one: 'انضم منذ يوم واحد', + ); + return '$_temp0'; + } + + @override + String contactJoinedMonthsAgo(num months) { + String _temp0 = intl.Intl.pluralLogic( + months, + locale: localeName, + other: 'انضم منذ $months شهر', + many: 'انضم منذ $months شهرًا', + few: 'انضم منذ $months أشهر', + two: 'انضم منذ شهرين', + one: 'انضم منذ شهر واحد', + ); + return '$_temp0'; + } + + @override + String contactJoinedYearsAgo(num years) { + String _temp0 = intl.Intl.pluralLogic( + years, + locale: localeName, + other: 'انضم منذ $years سنة', + many: 'انضم منذ $years سنة', + few: 'انضم منذ $years سنوات', + two: 'انضم منذ سنتين', + one: 'انضم منذ سنة واحدة', + ); + return '$_temp0'; + } + @override String get privacyVisibilityNobody => 'لا أحد'; @@ -3043,4 +3088,54 @@ class AppLocalizationsAr extends AppLocalizations { @override String get deleteSticker => 'حذف الملصق'; + + @override + String get releaseNotesTitle => 'ما الجديد؟'; + + @override + String get releaseNotesIntroduction => + 'وصل أكبر تحديث لـ twonly حتى الآن. إليك نظرة سريعة على الجديد.'; + + @override + String get releaseNotesAlsoImproved => 'تحسينات أخرى'; + + @override + String get releaseNotesStickers => 'الملصقات'; + + @override + String get releaseNotesStickersDescription => + 'حوّل صورك إلى ملصقات وشاركها في المحادثات.'; + + @override + String get releaseNotesWidgets => 'الأدوات'; + + @override + String get releaseNotesWidgetsDescription => + 'شاهد أحدث صور الأشخاص المفضلين لديك مباشرة على شاشتك الرئيسية.'; + + @override + String get releaseNotesStories => 'القصص'; + + @override + String get releaseNotesStoriesDescription => + 'شارك لحظاتك مع جهات اتصالك لمدة 24 ساعة.'; + + @override + String get releaseNotesMiniGames => 'ألعاب مصغّرة في المحادثات والمجموعات'; + + @override + String get releaseNotesMiniGamesDescription => + 'العبوا ألعابًا صغيرة معًا داخل محادثاتكم مباشرة.'; + + @override + String get releaseNotesReliableMessages => 'موثوقية أفضل للرسائل والإشعارات'; + + @override + String get releaseNotesImprovedUi => 'واجهة مستخدم محسّنة'; + + @override + String get releaseNotesCustomAvatars => 'صور رمزية مخصّصة'; + + @override + String get releaseNotesArabic => 'اللغة العربية (تجريبية)'; } diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 06d2f468..f9e66359 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -319,6 +319,42 @@ class AppLocalizationsDe extends AppLocalizations { String get contactTwonlyScoreDescription => 'Der twonly Score spiegelt die Aktivität einer Person auf twonly wider. Er wird aus verschiedenen Faktoren berechnet, zum Beispiel daraus, wie viele Fotos und Videos sie sendet und empfängt.'; + @override + String get contactJoinedToday => 'Heute beigetreten'; + + @override + String contactJoinedDaysAgo(num days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: 'Vor $days Tagen beigetreten', + one: 'Vor 1 Tag beigetreten', + ); + return '$_temp0'; + } + + @override + String contactJoinedMonthsAgo(num months) { + String _temp0 = intl.Intl.pluralLogic( + months, + locale: localeName, + other: 'Vor $months Monaten beigetreten', + one: 'Vor 1 Monat beigetreten', + ); + return '$_temp0'; + } + + @override + String contactJoinedYearsAgo(num years) { + String _temp0 = intl.Intl.pluralLogic( + years, + locale: localeName, + other: 'Vor $years Jahren beigetreten', + one: 'Vor 1 Jahr beigetreten', + ); + return '$_temp0'; + } + @override String get privacyVisibilityNobody => 'Niemand'; @@ -3045,4 +3081,55 @@ class AppLocalizationsDe extends AppLocalizations { @override String get deleteSticker => 'Sticker löschen'; + + @override + String get releaseNotesTitle => 'Was ist neu?'; + + @override + String get releaseNotesIntroduction => + 'Das bisher größte twonly-Update ist da. Hier ist ein kurzer Überblick.'; + + @override + String get releaseNotesAlsoImproved => 'Außerdem verbessert'; + + @override + String get releaseNotesStickers => 'Sticker'; + + @override + String get releaseNotesStickersDescription => + 'Erstelle Sticker aus deinen Fotos und teile sie in Chats.'; + + @override + String get releaseNotesWidgets => 'Widgets'; + + @override + String get releaseNotesWidgetsDescription => + 'Sieh neue Fotos deiner Lieblingsmenschen direkt auf dem Homescreen.'; + + @override + String get releaseNotesStories => 'Storys'; + + @override + String get releaseNotesStoriesDescription => + 'Teile Momente für 24 Stunden mit deinen Kontakten.'; + + @override + String get releaseNotesMiniGames => 'Minispiele in Chats & Gruppen'; + + @override + String get releaseNotesMiniGamesDescription => + 'Spielt kleine Spiele gemeinsam direkt in euren Unterhaltungen.'; + + @override + String get releaseNotesReliableMessages => + 'Zuverlässigere Nachrichten und Push-Benachrichtigungen'; + + @override + String get releaseNotesImprovedUi => 'Verbesserte Benutzeroberfläche'; + + @override + String get releaseNotesCustomAvatars => 'Individuelle Avatare'; + + @override + String get releaseNotesArabic => 'Arabische Sprache (experimentell)'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index fc5a2ee7..f2c7444f 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -316,6 +316,42 @@ class AppLocalizationsEn extends AppLocalizations { String get contactTwonlyScoreDescription => 'The twonly Score reflects a person\'s activity on twonly. It is calculated from several factors, such as how many photos and videos they send and receive.'; + @override + String get contactJoinedToday => 'Joined today'; + + @override + String contactJoinedDaysAgo(num days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: 'Joined $days days ago', + one: 'Joined 1 day ago', + ); + return '$_temp0'; + } + + @override + String contactJoinedMonthsAgo(num months) { + String _temp0 = intl.Intl.pluralLogic( + months, + locale: localeName, + other: 'Joined $months months ago', + one: 'Joined 1 month ago', + ); + return '$_temp0'; + } + + @override + String contactJoinedYearsAgo(num years) { + String _temp0 = intl.Intl.pluralLogic( + years, + locale: localeName, + other: 'Joined $years years ago', + one: 'Joined 1 year ago', + ); + return '$_temp0'; + } + @override String get privacyVisibilityNobody => 'Nobody'; @@ -3014,4 +3050,55 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deleteSticker => 'Delete sticker'; + + @override + String get releaseNotesTitle => 'What\'s new'; + + @override + String get releaseNotesIntroduction => + 'The biggest twonly update yet is here. Here\'s a quick look at what\'s new.'; + + @override + String get releaseNotesAlsoImproved => 'Also improved'; + + @override + String get releaseNotesStickers => 'Stickers'; + + @override + String get releaseNotesStickersDescription => + 'Turn your photos into stickers and share them in chats.'; + + @override + String get releaseNotesWidgets => 'Widgets'; + + @override + String get releaseNotesWidgetsDescription => + 'See fresh photos from your favorite people right on your home screen.'; + + @override + String get releaseNotesStories => 'Stories'; + + @override + String get releaseNotesStoriesDescription => + 'Share moments with your contacts for 24 hours.'; + + @override + String get releaseNotesMiniGames => 'Mini games in chats & groups'; + + @override + String get releaseNotesMiniGamesDescription => + 'Play small games together, right inside your conversations.'; + + @override + String get releaseNotesReliableMessages => + 'Better message and push reliability'; + + @override + String get releaseNotesImprovedUi => 'Improved interface'; + + @override + String get releaseNotesCustomAvatars => 'Custom avatars'; + + @override + String get releaseNotesArabic => 'Arabic language (experimental)'; } diff --git a/lib/src/localization/translations/ar.arb b/lib/src/localization/translations/ar.arb index 144bf5c8..1c4ea36f 100644 --- a/lib/src/localization/translations/ar.arb +++ b/lib/src/localization/translations/ar.arb @@ -97,6 +97,10 @@ "publicProfilePrivacyDescription": "اختر من يمكنه رؤية معلومات ملفك الشخصي.", "publicProfileTwonlyScore": "نقاط twonly", "contactTwonlyScoreDescription": "تعكس نقاط twonly نشاط الشخص على twonly. ويتم احتسابها استنادًا إلى عدة عوامل، مثل عدد الصور ومقاطع الفيديو التي يرسلها ويستلمها.", + "contactJoinedToday": "انضم اليوم", + "contactJoinedDaysAgo": "{days, plural, =1{انضم منذ يوم واحد} =2{انضم منذ يومين} few{انضم منذ {days} أيام} many{انضم منذ {days} يومًا} other{انضم منذ {days} يوم}}", + "contactJoinedMonthsAgo": "{months, plural, =1{انضم منذ شهر واحد} =2{انضم منذ شهرين} few{انضم منذ {months} أشهر} many{انضم منذ {months} شهرًا} other{انضم منذ {months} شهر}}", + "contactJoinedYearsAgo": "{years, plural, =1{انضم منذ سنة واحدة} =2{انضم منذ سنتين} few{انضم منذ {years} سنوات} many{انضم منذ {years} سنة} other{انضم منذ {years} سنة}}", "privacyVisibilityNobody": "لا أحد", "privacyVisibilityOnlyContacts": "جهات الاتصال فقط", "privacyVisibilityEveryone": "الجميع", @@ -865,5 +869,20 @@ "notificationSticker": "أرسل ملصقًا{inGroup}.", "storeSticker": "حفظ الملصق", "removeSticker": "إزالة من المجموعة", - "deleteSticker": "حذف الملصق" + "deleteSticker": "حذف الملصق", + "releaseNotesTitle": "ما الجديد؟", + "releaseNotesIntroduction": "وصل أكبر تحديث لـ twonly حتى الآن. إليك نظرة سريعة على الجديد.", + "releaseNotesAlsoImproved": "تحسينات أخرى", + "releaseNotesStickers": "الملصقات", + "releaseNotesStickersDescription": "حوّل صورك إلى ملصقات وشاركها في المحادثات.", + "releaseNotesWidgets": "الأدوات", + "releaseNotesWidgetsDescription": "شاهد أحدث صور الأشخاص المفضلين لديك مباشرة على شاشتك الرئيسية.", + "releaseNotesStories": "القصص", + "releaseNotesStoriesDescription": "شارك لحظاتك مع جهات اتصالك لمدة 24 ساعة.", + "releaseNotesMiniGames": "ألعاب مصغّرة في المحادثات والمجموعات", + "releaseNotesMiniGamesDescription": "العبوا ألعابًا صغيرة معًا داخل محادثاتكم مباشرة.", + "releaseNotesReliableMessages": "موثوقية أفضل للرسائل والإشعارات", + "releaseNotesImprovedUi": "واجهة مستخدم محسّنة", + "releaseNotesCustomAvatars": "صور رمزية مخصّصة", + "releaseNotesArabic": "اللغة العربية (تجريبية)" } diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index f3a9b78a..4ce34d15 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -106,6 +106,10 @@ "publicProfilePrivacyDescription": "Wähle aus, wer Informationen über dein Profil sehen kann.", "publicProfileTwonlyScore": "twonly Score", "contactTwonlyScoreDescription": "Der twonly Score spiegelt die Aktivität einer Person auf twonly wider. Er wird aus verschiedenen Faktoren berechnet, zum Beispiel daraus, wie viele Fotos und Videos sie sendet und empfängt.", + "contactJoinedToday": "Heute beigetreten", + "contactJoinedDaysAgo": "{days, plural, =1{Vor 1 Tag beigetreten} other{Vor {days} Tagen beigetreten}}", + "contactJoinedMonthsAgo": "{months, plural, =1{Vor 1 Monat beigetreten} other{Vor {months} Monaten beigetreten}}", + "contactJoinedYearsAgo": "{years, plural, =1{Vor 1 Jahr beigetreten} other{Vor {years} Jahren beigetreten}}", "privacyVisibilityNobody": "Niemand", "privacyVisibilityOnlyContacts": "Nur Kontakte", "privacyVisibilityEveryone": "Alle", @@ -1178,5 +1182,20 @@ "notificationSticker": "hat einen Sticker gesendet{inGroup}.", "storeSticker": "Sticker speichern", "removeSticker": "Aus Sammlung entfernen", - "deleteSticker": "Sticker löschen" + "deleteSticker": "Sticker löschen", + "releaseNotesTitle": "Was ist neu?", + "releaseNotesIntroduction": "Das bisher größte twonly-Update ist da. Hier ist ein kurzer Überblick.", + "releaseNotesAlsoImproved": "Außerdem verbessert", + "releaseNotesStickers": "Sticker", + "releaseNotesStickersDescription": "Erstelle Sticker aus deinen Fotos und teile sie in Chats.", + "releaseNotesWidgets": "Widgets", + "releaseNotesWidgetsDescription": "Sieh neue Fotos deiner Lieblingsmenschen direkt auf dem Homescreen.", + "releaseNotesStories": "Storys", + "releaseNotesStoriesDescription": "Teile Momente für 24 Stunden mit deinen Kontakten.", + "releaseNotesMiniGames": "Minispiele in Chats & Gruppen", + "releaseNotesMiniGamesDescription": "Spielt kleine Spiele gemeinsam direkt in euren Unterhaltungen.", + "releaseNotesReliableMessages": "Zuverlässigere Nachrichten und Push-Benachrichtigungen", + "releaseNotesImprovedUi": "Verbesserte Benutzeroberfläche", + "releaseNotesCustomAvatars": "Individuelle Avatare", + "releaseNotesArabic": "Arabische Sprache (experimentell)" } diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index 6b209619..0479b7bb 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -102,6 +102,10 @@ "publicProfilePrivacyDescription": "Choose who can see information about your profile.", "publicProfileTwonlyScore": "twonly Score", "contactTwonlyScoreDescription": "The twonly Score reflects a person's activity on twonly. It is calculated from several factors, such as how many photos and videos they send and receive.", + "contactJoinedToday": "Joined today", + "contactJoinedDaysAgo": "{days, plural, =1{Joined 1 day ago} other{Joined {days} days ago}}", + "contactJoinedMonthsAgo": "{months, plural, =1{Joined 1 month ago} other{Joined {months} months ago}}", + "contactJoinedYearsAgo": "{years, plural, =1{Joined 1 year ago} other{Joined {years} years ago}}", "privacyVisibilityNobody": "Nobody", "privacyVisibilityOnlyContacts": "Only Contacts", "privacyVisibilityEveryone": "Everyone", @@ -1188,5 +1192,20 @@ "notificationSticker": "sent a sticker{inGroup}.", "storeSticker": "Save sticker", "removeSticker": "Remove from collection", - "deleteSticker": "Delete sticker" + "deleteSticker": "Delete sticker", + "releaseNotesTitle": "What's new", + "releaseNotesIntroduction": "The biggest twonly update yet is here. Here's a quick look at what's new.", + "releaseNotesAlsoImproved": "Also improved", + "releaseNotesStickers": "Stickers", + "releaseNotesStickersDescription": "Turn your photos into stickers and share them in chats.", + "releaseNotesWidgets": "Widgets", + "releaseNotesWidgetsDescription": "See fresh photos from your favorite people right on your home screen.", + "releaseNotesStories": "Stories", + "releaseNotesStoriesDescription": "Share moments with your contacts for 24 hours.", + "releaseNotesMiniGames": "Mini games in chats & groups", + "releaseNotesMiniGamesDescription": "Play small games together, right inside your conversations.", + "releaseNotesReliableMessages": "Better message and push reliability", + "releaseNotesImprovedUi": "Improved interface", + "releaseNotesCustomAvatars": "Custom avatars", + "releaseNotesArabic": "Arabic language (experimental)" } diff --git a/lib/src/services/api/api.service.dart b/lib/src/services/api/api.service.dart index f41841cd..c8046bd8 100644 --- a/lib/src/services/api/api.service.dart +++ b/lib/src/services/api/api.service.dart @@ -68,6 +68,10 @@ class ApiService { rust_api.RustApi.reuploadPendingMedia(), 'reuploadPendingMedia', ); + unawaitedRustCall( + rust_api.RustApi.refreshContactJoinedDates(), + 'refreshContactJoinedDates', + ); twonlyDB.markUpdated(); // resetUserDiscoveryRequestUpdates(); diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index 6109b7b0..31729e29 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -99,6 +99,22 @@ String formatDuration(BuildContext context, int seconds) { } } +String formatJoinedAt(BuildContext context, DateTime joinedAt) { + final elapsed = clock.now().toUtc().difference(joinedAt.toUtc()); + final days = max(0, elapsed.inDays); + + if (days == 0) { + return context.lang.contactJoinedToday; + } + if (days < 30) { + return context.lang.contactJoinedDaysAgo(days); + } + if (days < 365) { + return context.lang.contactJoinedMonthsAgo(days ~/ 30); + } + return context.lang.contactJoinedYearsAgo(days ~/ 365); +} + Future authenticateUser( String localizedReason, { bool force = true, diff --git a/lib/src/visual/components/release_notes.comp.dart b/lib/src/visual/components/release_notes.comp.dart new file mode 100644 index 00000000..cd6c0eea --- /dev/null +++ b/lib/src/visual/components/release_notes.comp.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/services/user.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; + +@immutable +class ReleaseNoteFeature { + const ReleaseNoteFeature({ + required this.icon, + required this.title, + required this.description, + }); + + final Widget icon; + final String title; + final String description; +} + +@immutable +class ReleaseNotesData { + const ReleaseNotesData({ + required this.version, + required this.title, + required this.introduction, + required this.additionalChangesTitle, + required this.closeLabel, + required this.features, + required this.additionalChanges, + }); + + final String version; + final String title; + final String introduction; + final String additionalChangesTitle; + final String closeLabel; + final List features; + final List additionalChanges; +} + +/// Coordinates the once-per-version behavior for any release note. +class ReleaseNotesPresenter { + ReleaseNotesPresenter._(); + + static final Set _showingVersions = {}; + static final Set _shownThisSession = {}; + + static Future showIfNeeded( + BuildContext context, + ReleaseNotesData releaseNotes, + ) async { + final version = releaseNotes.version; + if (_showingVersions.contains(version) || + _shownThisSession.contains(version) || + userService.currentUser.lastReleaseNotesVersion == version) { + return; + } + + _showingVersions.add(version); + try { + await showReleaseNotesBottomSheet(context, releaseNotes); + _shownThisSession.add(version); + await UserService.update( + (user) => user.lastReleaseNotesVersion = version, + ); + } finally { + _showingVersions.remove(version); + } + } +} + +Future showReleaseNotesBottomSheet( + BuildContext context, + ReleaseNotesData releaseNotes, +) { + return showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => FractionallySizedBox( + heightFactor: 0.8, + child: ReleaseNotesBottomSheet(releaseNotes: releaseNotes), + ), + ); +} + +class ReleaseNotesBottomSheet extends StatelessWidget { + const ReleaseNotesBottomSheet({ + required this.releaseNotes, + super.key, + }); + + final ReleaseNotesData releaseNotes; + + @override + Widget build(BuildContext context) { + return Material( + color: context.color.surface, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: SafeArea( + top: false, + child: Column( + children: [ + const _DragHandle(), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + children: [ + _Header(releaseNotes: releaseNotes), + const SizedBox(height: 24), + ...releaseNotes.features.map( + (feature) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _FeatureCard(feature: feature), + ), + ), + if (releaseNotes.additionalChanges.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + releaseNotes.additionalChangesTitle, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: context.color.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + ...releaseNotes.additionalChanges.map( + (change) => _AdditionalChange(text: change), + ), + ], + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + child: MyButton( + onPressed: () => Navigator.pop(context), + child: Text(releaseNotes.closeLabel), + ), + ), + ], + ), + ), + ); + } +} + +class _DragHandle extends StatelessWidget { + const _DragHandle(); + + @override + Widget build(BuildContext context) { + return Container( + width: 42, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: context.color.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.releaseNotes}); + + final ReleaseNotesData releaseNotes; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: context.color.primaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Text( + 'v${releaseNotes.version}', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: context.color.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 12), + Text( + releaseNotes.title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + releaseNotes.introduction, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + height: 1.35, + ), + ), + ], + ); + } +} + +class _FeatureCard extends StatelessWidget { + const _FeatureCard({required this.feature}); + + final ReleaseNoteFeature feature; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: context.color.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: context.color.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: IconTheme( + data: IconThemeData( + size: 20, + color: context.color.onPrimaryContainer, + ), + child: feature.icon, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + feature.title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + feature.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.color.onSurfaceVariant, + height: 1.35, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _AdditionalChange extends StatelessWidget { + const _AdditionalChange({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: context.color.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_rounded, + size: 15, + color: context.color.onPrimaryContainer, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/visual/release_notes/release_notes_0_6_0.dart b/lib/src/visual/release_notes/release_notes_0_6_0.dart new file mode 100644 index 00000000..361b8ae6 --- /dev/null +++ b/lib/src/visual/release_notes/release_notes_0_6_0.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/release_notes.comp.dart'; + +ReleaseNotesData releaseNotes060(BuildContext context) { + final lang = context.lang; + return ReleaseNotesData( + version: '0.6.0', + title: lang.releaseNotesTitle, + introduction: lang.releaseNotesIntroduction, + additionalChangesTitle: lang.releaseNotesAlsoImproved, + closeLabel: lang.close, + features: [ + ReleaseNoteFeature( + icon: const FaIcon(FontAwesomeIcons.noteSticky), + title: lang.releaseNotesStickers, + description: lang.releaseNotesStickersDescription, + ), + ReleaseNoteFeature( + icon: const FaIcon(FontAwesomeIcons.tableCellsLarge), + title: lang.releaseNotesWidgets, + description: lang.releaseNotesWidgetsDescription, + ), + ReleaseNoteFeature( + icon: const FaIcon(FontAwesomeIcons.circlePlay), + title: lang.releaseNotesStories, + description: lang.releaseNotesStoriesDescription, + ), + ReleaseNoteFeature( + icon: const FaIcon(FontAwesomeIcons.gamepad), + title: lang.releaseNotesMiniGames, + description: lang.releaseNotesMiniGamesDescription, + ), + ], + additionalChanges: [ + lang.releaseNotesReliableMessages, + lang.releaseNotesImprovedUi, + lang.releaseNotesCustomAvatars, + lang.releaseNotesArabic, + ], + ); +} diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart index 452865e6..5b891be5 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart @@ -136,6 +136,14 @@ class _ChatAskAFriendEntryState extends State { requested: const Value(false), blocked: const Value(false), deletedByUser: const Value(false), + joinedAt: userdata.joinedAt == null + ? const Value.absent() + : Value( + DateTime.fromMillisecondsSinceEpoch( + userdata.joinedAt! * Duration.millisecondsPerSecond, + isUtc: true, + ), + ), ), ); await RustApi.tryRequestContactById( diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart index 7aa5205f..d0373798 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart @@ -170,6 +170,14 @@ class _ContactRowState extends State<_ContactRow> { requested: const Value(false), blocked: const Value(false), deletedByUser: const Value(false), + joinedAt: userdata.joinedAt == null + ? const Value.absent() + : Value( + DateTime.fromMillisecondsSinceEpoch( + userdata.joinedAt! * Duration.millisecondsPerSecond, + isUtc: true, + ), + ), ), ); diff --git a/lib/src/visual/views/contact/add_new_contact.view.dart b/lib/src/visual/views/contact/add_new_contact.view.dart index 332535bd..82eaee05 100644 --- a/lib/src/visual/views/contact/add_new_contact.view.dart +++ b/lib/src/visual/views/contact/add_new_contact.view.dart @@ -158,6 +158,14 @@ class _SearchUsernameView extends State { requested: const Value(false), blocked: const Value(false), deletedByUser: const Value(false), + joinedAt: userdata.joinedAt == null + ? const Value.absent() + : Value( + DateTime.fromMillisecondsSinceEpoch( + userdata.joinedAt! * Duration.millisecondsPerSecond, + isUtc: true, + ), + ), ), ); diff --git a/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart b/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart index f2a565b9..99cda997 100644 --- a/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart +++ b/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart @@ -68,6 +68,14 @@ class FriendSuggestionsComp extends StatelessWidget { requested: const Value(false), blocked: const Value(false), deletedByUser: const Value(false), + joinedAt: userdata.joinedAt == null + ? const Value.absent() + : Value( + DateTime.fromMillisecondsSinceEpoch( + userdata.joinedAt! * Duration.millisecondsPerSecond, + isUtc: true, + ), + ), ), ); diff --git a/lib/src/visual/views/contact/contact.view.dart b/lib/src/visual/views/contact/contact.view.dart index f1c104a9..bf7eceb0 100644 --- a/lib/src/visual/views/contact/contact.view.dart +++ b/lib/src/visual/views/contact/contact.view.dart @@ -226,36 +226,64 @@ class _ContactViewState extends State { ), if (getContactDisplayName(contact) != contact.username) Center(child: Text('(${contact.username})')), - if (contact.twonlyScore != null) + if (contact.twonlyScore != null || + (contact.joinedAt != null && !contact.accountDeleted)) Padding( padding: const EdgeInsets.only(top: 10), child: Center( - child: IntrinsicWidth( - child: MyChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const FaIcon(FontAwesomeIcons.trophy, size: 14), - const SizedBox(width: 7), - Text('${contact.twonlyScore}'), - ], - ), - onTap: () => showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.lang.publicProfileTwonlyScore), - content: Text( - context.lang.contactTwonlyScoreDescription, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.lang.ok), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + if (contact.twonlyScore != null) + IntrinsicWidth( + child: MyChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FaIcon(FontAwesomeIcons.trophy, size: 14), + const SizedBox(width: 7), + Text('${contact.twonlyScore}'), + ], ), - ], + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + context.lang.publicProfileTwonlyScore, + ), + content: Text( + context.lang.contactTwonlyScoreDescription, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.lang.ok), + ), + ], + ), + ), + ), ), - ), - ), + if (contact.joinedAt != null && !contact.accountDeleted) + IntrinsicWidth( + child: MyChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FaIcon( + FontAwesomeIcons.calendarDay, + size: 14, + ), + const SizedBox(width: 7), + Text(formatJoinedAt(context, contact.joinedAt!)), + ], + ), + onTap: null, + ), + ), + ], ), ), ), diff --git a/lib/src/visual/views/home.view.dart b/lib/src/visual/views/home.view.dart index c95ce704..d02f795b 100644 --- a/lib/src/visual/views/home.view.dart +++ b/lib/src/visual/views/home.view.dart @@ -17,6 +17,8 @@ import 'package:twonly/src/services/notifications/native.notifications.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/release_notes.comp.dart'; +import 'package:twonly/src/visual/release_notes/release_notes_0_6_0.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart'; @@ -45,6 +47,8 @@ class HomeViewState extends State with WidgetsBindingObserver { /// Whether [_openEditor] has the editor open over this view. bool _editorOpen = false; bool _startPreloading = false; + bool _startupChecksComplete = false; + bool _releaseNotesCheckScheduled = false; final MainCameraController _mainCameraController = MainCameraController(); late final PageController _homeViewPageController; @@ -67,6 +71,7 @@ class HomeViewState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + routerProvider.routerDelegate.addListener(_onRouteChanged); var initialPage = widget.initialPage; if (HomeViewState.pendingSharedLink != null) { initialPage = 1; @@ -97,6 +102,7 @@ class HomeViewState extends State with WidgetsBindingObserver { if (index != 1) { unawaited(_mainCameraController.closeCamera()); } + _scheduleReleaseNotesIfNeeded(); }); _selectNotificationSub = selectNotificationStream.stream.listen(( @@ -179,7 +185,12 @@ class HomeViewState extends State with WidgetsBindingObserver { }); } - unawaited(_initAsync()); + unawaited( + _initAsync().whenComplete(() { + _startupChecksComplete = true; + _scheduleReleaseNotesIfNeeded(); + }), + ); WidgetsBinding.instance.addPostFrameCallback((_) { if (_mainCameraController.sharedLinkForPreview == null && @@ -293,6 +304,7 @@ class HomeViewState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); + routerProvider.routerDelegate.removeListener(_onRouteChanged); _onMessageOpenedAppSub?.cancel(); _homeViewPageIndexSub?.cancel(); _selectNotificationSub?.cancel(); @@ -310,10 +322,29 @@ class HomeViewState extends State with WidgetsBindingObserver { return ModalRoute.of(context)?.isCurrent ?? false; } + void _onRouteChanged() => _scheduleReleaseNotesIfNeeded(); + + void _scheduleReleaseNotesIfNeeded() { + if (!_startupChecksComplete || + _releaseNotesCheckScheduled || + _activePageIdx != 0) { + return; + } + _releaseNotesCheckScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _releaseNotesCheckScheduled = false; + if (!mounted || _activePageIdx != 0 || !_isViewActive()) return; + unawaited( + ReleaseNotesPresenter.showIfNeeded(context, releaseNotes060(context)), + ); + }); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.resumed) { + _scheduleReleaseNotesIfNeeded(); if (AppState.hasCameraPermissions && _offsetRatio < 1 && !_mainCameraController.isSharePreviewIsShown && @@ -378,6 +409,9 @@ class HomeViewState extends State with WidgetsBindingObserver { _activePageIdx = pageIndex; } }); + if (notification is ScrollEndNotification) { + _scheduleReleaseNotesIfNeeded(); + } } if (AppState.hasCameraPermissions && @@ -505,7 +539,10 @@ class HomeViewState extends State with WidgetsBindingObserver { duration: const Duration(milliseconds: 100), curve: Curves.bounceIn, ); - if (mounted) setState(() {}); + if (mounted) { + setState(() {}); + _scheduleReleaseNotesIfNeeded(); + } }, currentIndex: _activePageIdx, ) diff --git a/rust/.sqlx/query-7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json b/rust/.sqlx/query-7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json deleted file mode 100644 index 61e62cec..00000000 --- a/rust/.sqlx/query-7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE contacts SET signal_version = 'v2', account_deleted = 0 WHERE user_id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4" -} diff --git a/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json b/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json index 10912e94..52339ac6 100644 --- a/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json +++ b/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json @@ -332,6 +332,17 @@ "name": "twonly_score" } } + }, + { + "name": "joined_at", + "ordinal": 30, + "type_info": "Integer", + "origin": { + "Table": { + "table": "contacts", + "name": "joined_at" + } + } } ], "parameters": { @@ -367,6 +378,7 @@ false, false, false, + true, true ] }, diff --git a/rust/src/api/messages/incoming/contact.rs b/rust/src/api/messages/incoming/contact.rs index be1d378f..11602a7a 100644 --- a/rust/src/api/messages/incoming/contact.rs +++ b/rust/src/api/messages/incoming/contact.rs @@ -146,6 +146,13 @@ pub(crate) async fn handle_contact_request( .build() .insert_on_conflict_update(tr) .await?; + if let Some(joined_at) = user.joined_at { + sqlx::query("UPDATE contacts SET joined_at = ? WHERE user_id = ?") + .bind(joined_at) + .bind(from_user_id) + .execute(&mut **tr) + .await?; + } } } Type::Accept => { diff --git a/rust/src/api/proto/api/websocket/server_to_client.proto b/rust/src/api/proto/api/websocket/server_to_client.proto index 4778e11b..1bbbf20d 100644 --- a/rust/src/api/proto/api/websocket/server_to_client.proto +++ b/rust/src/api/proto/api/websocket/server_to_client.proto @@ -134,6 +134,7 @@ message Response { optional int64 signed_prekey_id = 6; optional int64 registration_id = 8; optional PqcBundle pqc_bundle = 9; + optional int64 joined_at = 10; } message UploadToken { diff --git a/rust/src/api/server/contacts.rs b/rust/src/api/server/contacts.rs index 0e670b55..4e8f504c 100644 --- a/rust/src/api/server/contacts.rs +++ b/rust/src/api/server/contacts.rs @@ -38,6 +38,39 @@ impl Server { }) } + /// Loads the immutable account creation date for contacts that do not + /// already have it. Server-deleted contacts are deliberately excluded. + pub async fn refresh_contact_joined_dates(ctx: &Arc) -> Result<()> { + let database = ctx.app_db.read().await.clone(); + let contact_ids = sqlx::query_scalar::<_, i64>( + "SELECT user_id FROM contacts WHERE account_deleted = 0 AND joined_at IS NULL", + ) + .fetch_all(&database.pool) + .await?; + + for contact_id in contact_ids { + let user = match Self::get_user_by_id(ctx, contact_id).await? { + ServerResult::Ok(user) => user, + // The shared API error handler marks UserIdNotFound contacts + // as deleted. Other per-contact errors should not prevent the + // remaining contacts from being refreshed. + ServerResult::ErrorCode(_) => continue, + }; + let Some(joined_at) = user.joined_at else { + // Backwards compatibility while an older server is still + // running: leave the value NULL so a later startup retries it. + continue; + }; + sqlx::query("UPDATE contacts SET joined_at = ? WHERE user_id = ?") + .bind(joined_at) + .bind(contact_id) + .execute(&database.pool) + .await?; + } + + Ok(()) + } + pub async fn check_for_deleted_usernames(ctx: &Arc) -> Result<()> { let database = ctx.app_db.read().await.clone(); let contacts = sqlx::query_scalar!( diff --git a/rust/src/bridge/api.rs b/rust/src/bridge/api.rs index efe9580d..fc5e7d91 100644 --- a/rust/src/bridge/api.rs +++ b/rust/src/bridge/api.rs @@ -112,6 +112,7 @@ pub struct FrbUserData { pub user_id: i64, pub username: Vec, pub public_identity_key: Vec, + pub joined_at: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -593,8 +594,13 @@ impl RustApi { user_id: res.user_id, username: res.username.unwrap_or_default(), public_identity_key: res.public_identity_key.unwrap_or_default(), + joined_at: res.joined_at, }) } + pub async fn refresh_contact_joined_dates() -> Result<()> { + let ctx = Context::get_static()?; + Server::refresh_contact_joined_dates(ctx).await + } pub async fn check_for_deleted_usernames() -> Result<()> { let ctx = Context::get_static()?; Server::check_for_deleted_usernames(ctx).await @@ -614,6 +620,7 @@ impl RustApi { user_id: res.user_id, username: res.username.unwrap_or_default(), public_identity_key: res.public_identity_key.unwrap_or_default(), + joined_at: res.joined_at, }) } pub async fn get_proof_of_work() -> Result { diff --git a/rust/src/database/app/migrations/0030_contact_joined_at.sql b/rust/src/database/app/migrations/0030_contact_joined_at.sql new file mode 100644 index 00000000..7d8ff883 --- /dev/null +++ b/rust/src/database/app/migrations/0030_contact_joined_at.sql @@ -0,0 +1,3 @@ +-- UTC Unix timestamp at which the contact registered with twonly. NULL until +-- it has been loaded from a server that supports this profile field. +ALTER TABLE contacts ADD COLUMN joined_at INTEGER; diff --git a/rust/src/database/app/mod.rs b/rust/src/database/app/mod.rs index 02dc6535..303699ee 100644 --- a/rust/src/database/app/mod.rs +++ b/rust/src/database/app/mod.rs @@ -16,7 +16,7 @@ mod legacy_import; pub mod tables; pub const APP_DATABASE_FILE: &str = "app_db.sqlite"; -pub const APP_SCHEMA_VERSION: i64 = 14; +pub const APP_SCHEMA_VERSION: i64 = 15; /// User-owned application tables in the current Rust schema. Rust-only outbox /// tables are deliberately absent because they are reconstructed locally. @@ -402,6 +402,34 @@ mod change_notification_tests { (directory, database) } + #[tokio::test] + async fn contact_joined_at_is_nullable_and_persisted() { + let (_dir, database) = open().await; + sqlx::query("INSERT INTO contacts(user_id, username) VALUES(7, 'alice')") + .execute(&database.pool) + .await + .unwrap(); + + let joined_at: Option = + sqlx::query_scalar("SELECT joined_at FROM contacts WHERE user_id = 7") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(joined_at, None); + + sqlx::query("UPDATE contacts SET joined_at = ? WHERE user_id = 7") + .bind(1_700_000_000_i64) + .execute(&database.pool) + .await + .unwrap(); + let joined_at: Option = + sqlx::query_scalar("SELECT joined_at FROM contacts WHERE user_id = 7") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(joined_at, Some(1_700_000_000)); + } + fn insert(key: &str) -> String { format!("INSERT INTO app_metadata(key, value) VALUES('{key}', '1')") } diff --git a/rust/src/database/app/tables/contact.rs b/rust/src/database/app/tables/contact.rs index dd1cf852..3c2703d2 100644 --- a/rust/src/database/app/tables/contact.rs +++ b/rust/src/database/app/tables/contact.rs @@ -42,6 +42,7 @@ pub struct Contact { pub widget_sharing_allowed: i64, pub widget_sharing_granted: i64, pub twonly_score: Option, + pub joined_at: Option, } #[derive(bon::Builder)] diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index e3959f2e..911fa706 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1844664049; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1901082359; // Section: executor @@ -3479,6 +3479,42 @@ fn wire__crate__bridge__api__rust_api_purge_widget_media_impl( }, ) } +fn wire__crate__bridge__api__rust_api_refresh_contact_joined_dates_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_refresh_contact_joined_dates", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::refresh_contact_joined_dates().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -7055,10 +7091,12 @@ impl SseDecode for crate::bridge::api::FrbUserData { let mut var_userId = ::sse_decode(deserializer); let mut var_username = >::sse_decode(deserializer); let mut var_publicIdentityKey = >::sse_decode(deserializer); + let mut var_joinedAt = >::sse_decode(deserializer); return crate::bridge::api::FrbUserData { user_id: var_userId, username: var_username, public_identity_key: var_publicIdentityKey, + joined_at: var_joinedAt, }; } } @@ -7944,6 +7982,7 @@ impl SseDecode for crate::user_config::UserConfig { let mut var_currentSignedPreKeyIndexStart = ::sse_decode(deserializer); let mut var_lastChangeLogHash = >>::sse_decode(deserializer); let mut var_hideChangeLog = ::sse_decode(deserializer); + let mut var_lastReleaseNotesVersion = >::sse_decode(deserializer); let mut var_hideMemoriesBackupPromo = ::sse_decode(deserializer); let mut var_hideWidgetShareExplainer = ::sse_decode(deserializer); let mut var_updateFcmToken = ::sse_decode(deserializer); @@ -8013,6 +8052,7 @@ impl SseDecode for crate::user_config::UserConfig { current_signed_pre_key_index_start: var_currentSignedPreKeyIndexStart, last_change_log_hash: var_lastChangeLogHash, hide_change_log: var_hideChangeLog, + last_release_notes_version: var_lastReleaseNotesVersion, hide_memories_backup_promo: var_hideMemoriesBackupPromo, hide_widget_share_explainer: var_hideWidgetShareExplainer, update_fcm_token: var_updateFcmToken, @@ -8246,92 +8286,93 @@ fn pde_ffi_dispatcher_primary_impl( 91 => wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(port, ptr, rust_vec_len, data_len), 92 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len), 93 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len), -94 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len), -95 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), -96 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len), -97 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -98 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -99 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), -100 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), -101 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len), -102 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), -103 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), -104 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), -105 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), -106 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), -107 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), -108 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len), -109 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len), -110 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len), -111 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), -112 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), -113 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), -114 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), -115 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len), -116 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), -117 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), -118 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), -119 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), -120 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len), -121 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), -122 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len), -123 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len), -124 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len), -125 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), -126 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len), -127 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), -128 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len), -129 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len), -130 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len), -131 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len), -132 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), -133 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), -134 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), -135 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), -136 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), -137 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), -138 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), -139 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), -140 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), -141 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), -142 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), -143 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), -144 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -145 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), -146 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -147 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), -148 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -149 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -150 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -151 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), -152 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), -153 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), -154 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), -155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len), -156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), -157 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), -158 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), -159 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), -160 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), -161 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), -162 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), -163 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len), -164 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), -165 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), -166 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len), -167 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), -168 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), -169 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len), -170 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len), -171 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len), -172 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), -173 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), -174 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len), -176 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), -177 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), -178 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), -179 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), -180 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), +94 => wire__crate__bridge__api__rust_api_refresh_contact_joined_dates_impl(port, ptr, rust_vec_len, data_len), +95 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len), +96 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), +97 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len), +98 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), +99 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +100 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), +101 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), +102 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len), +103 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), +104 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), +105 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), +106 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), +107 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), +108 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), +109 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len), +110 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len), +111 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len), +112 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), +113 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), +114 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), +115 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), +116 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len), +117 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), +118 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), +119 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), +120 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), +121 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len), +122 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), +123 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len), +124 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len), +125 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len), +126 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), +127 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len), +128 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), +129 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len), +130 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len), +131 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len), +132 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len), +133 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), +134 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), +135 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), +136 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), +137 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), +138 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), +139 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), +140 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), +141 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), +142 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), +143 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), +144 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), +145 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +146 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), +147 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +148 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), +149 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +150 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +151 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +152 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), +153 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), +154 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), +155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), +156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len), +157 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), +158 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), +159 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), +160 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), +161 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), +162 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), +163 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), +164 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len), +165 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), +166 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), +167 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len), +168 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), +169 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), +170 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len), +171 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len), +172 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len), +173 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), +174 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), +175 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len), +177 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), +178 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), +179 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), +180 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), +181 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -8350,12 +8391,12 @@ fn pde_ffi_dispatcher_sync_impl( 55 => { wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len) } - 175 => wire__crate__bridge__user_config__user_config_api_clone_impl( + 176 => wire__crate__bridge__user_config__user_config_api_clone_impl( ptr, rust_vec_len, data_len, ), - 181 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), + 182 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -8720,6 +8761,7 @@ impl flutter_rust_bridge::IntoDart for crate::bridge::api::FrbUserData { self.user_id.into_into_dart().into_dart(), self.username.into_into_dart().into_dart(), self.public_identity_key.into_into_dart().into_dart(), + self.joined_at.into_into_dart().into_dart(), ] .into_dart() } @@ -9321,6 +9363,7 @@ impl flutter_rust_bridge::IntoDart for crate::user_config::UserConfig { .into_dart(), self.last_change_log_hash.into_into_dart().into_dart(), self.hide_change_log.into_into_dart().into_dart(), + self.last_release_notes_version.into_into_dart().into_dart(), self.hide_memories_backup_promo.into_into_dart().into_dart(), self.hide_widget_share_explainer .into_into_dart() @@ -9758,6 +9801,7 @@ impl SseEncode for crate::bridge::api::FrbUserData { ::sse_encode(self.user_id, serializer); >::sse_encode(self.username, serializer); >::sse_encode(self.public_identity_key, serializer); + >::sse_encode(self.joined_at, serializer); } } @@ -10516,6 +10560,7 @@ impl SseEncode for crate::user_config::UserConfig { ::sse_encode(self.current_signed_pre_key_index_start, serializer); >>::sse_encode(self.last_change_log_hash, serializer); ::sse_encode(self.hide_change_log, serializer); + >::sse_encode(self.last_release_notes_version, serializer); ::sse_encode(self.hide_memories_backup_promo, serializer); ::sse_encode(self.hide_widget_share_explainer, serializer); ::sse_encode(self.update_fcm_token, serializer); diff --git a/rust/src/services/contacts.rs b/rust/src/services/contacts.rs index 7d397a69..cfe96371 100644 --- a/rust/src/services/contacts.rs +++ b/rust/src/services/contacts.rs @@ -51,6 +51,13 @@ impl ContactService { .build() .insert_on_conflict_update(&mut transaction) .await?; + if let Some(joined_at) = user.joined_at { + sqlx::query("UPDATE contacts SET joined_at = ? WHERE user_id = ?") + .bind(joined_at) + .bind(user.user_id) + .execute(&mut *transaction) + .await?; + } transaction.commit().await?; self.send_contact_request( @@ -113,10 +120,12 @@ impl ContactService { let database = self.ctx.app_db.read().await.clone(); // The server answered with a bundle, so a previous `UserIdNotFound` // (or a manual mark) must not keep the contact blocked. - sqlx::query!( - "UPDATE contacts SET signal_version = 'v2', account_deleted = 0 WHERE user_id = ?", - user_id + sqlx::query( + "UPDATE contacts SET signal_version = 'v2', account_deleted = 0, \ + joined_at = COALESCE(?, joined_at) WHERE user_id = ?", ) + .bind(user.joined_at) + .bind(user_id) .execute(&database.pool) .await?; release_deferred_receipts(&database, user_id).await?; diff --git a/rust/src/user_config.rs b/rust/src/user_config.rs index 572938f9..52a94a7e 100644 --- a/rust/src/user_config.rs +++ b/rust/src/user_config.rs @@ -361,6 +361,10 @@ pub struct UserConfig { #[serde(default = "defaults::true_value")] #[frb(non_final)] pub hide_change_log: bool, + /// Last in-app release note the user dismissed. + #[serde(default)] + #[frb(non_final)] + pub last_release_notes_version: Option, #[serde(default)] #[frb(non_final)] pub hide_memories_backup_promo: bool, @@ -551,6 +555,7 @@ mod tests { ); assert!(!config.is_user_discovery_enabled); assert_eq!(config.last_server_message_at, None); + assert_eq!(config.last_release_notes_version, None); } #[test] diff --git a/test/visual/components/release_notes_test.dart b/test/visual/components/release_notes_test.dart new file mode 100644 index 00000000..fd866653 --- /dev/null +++ b/test/visual/components/release_notes_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/visual/components/release_notes.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; + +const _releaseNotes = ReleaseNotesData( + version: '1.2.3', + title: "What's new", + introduction: 'A short introduction.', + additionalChangesTitle: 'Also improved', + closeLabel: 'Close', + features: [ + ReleaseNoteFeature( + icon: Icon(Icons.star_rounded), + title: 'First feature', + description: 'A short feature description.', + ), + ], + additionalChanges: ['One more improvement'], +); + +void main() { + testWidgets('shows generic release notes at 80 percent height', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => showReleaseNotesBottomSheet( + context, + _releaseNotes, + ), + child: const Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('v1.2.3'), findsOneWidget); + expect(find.text("What's new"), findsOneWidget); + expect(find.text('First feature'), findsOneWidget); + expect(find.text('One more improvement'), findsOneWidget); + expect(find.byType(MyButton), findsOneWidget); + + final sheet = find.byType(ReleaseNotesBottomSheet); + final mediaSize = MediaQuery.sizeOf(tester.element(sheet)); + expect(tester.getSize(sheet).height, closeTo(mediaSize.height * 0.8, 0.1)); + + await tester.tap(find.byType(MyButton)); + await tester.pumpAndSettle(); + expect(sheet, findsNothing); + }); +} diff --git a/test/visual/contact_joined_at_test.dart b/test/visual/contact_joined_at_test.dart new file mode 100644 index 00000000..e6b5b04d --- /dev/null +++ b/test/visual/contact_joined_at_test.dart @@ -0,0 +1,48 @@ +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/localization/generated/app_localizations.dart'; +import 'package:twonly/src/utils/misc.dart'; + +void main() { + testWidgets('formats contact join age into friendly calendar ranges', ( + tester, + ) async { + final now = DateTime.utc(2026, 10, 5, 12); + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (buildContext) { + context = buildContext; + return const SizedBox.shrink(); + }, + ), + ), + ); + + withClock(Clock.fixed(now), () { + expect(formatJoinedAt(context, now), 'Joined today'); + expect( + formatJoinedAt(context, now.subtract(const Duration(days: 20))), + 'Joined 20 days ago', + ); + expect( + formatJoinedAt(context, now.subtract(const Duration(days: 60))), + 'Joined 2 months ago', + ); + expect( + formatJoinedAt(context, now.subtract(const Duration(days: 730))), + 'Joined 2 years ago', + ); + }); + }); +}