From 5ba1a9db88dabf4458ddda65204e03baa008e286 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 27 Sep 2026 21:27:52 +0200 Subject: [PATCH] add stories feature --- .../NotificationService.entitlements | 2 + .../NotificationService.swift | 14 +- ios/Podfile.lock | 6 - .../xcshareddata/swiftpm/Package.resolved | 4 +- lib/app.dart | 3 + lib/core/bridge/api.dart | 6 +- lib/core/bridge/stories.dart | 48 ++ lib/core/frb_generated.dart | 628 ++++++++++++------ lib/core/frb_generated.io.dart | 38 ++ lib/core/frb_generated.web.dart | 38 ++ lib/core/user_config.dart | 7 + lib/main.dart | 2 + lib/src/constants/routes.keys.dart | 6 + lib/src/database/daos/contact_groups.dao.dart | 15 + lib/src/database/daos/mediafiles.dao.dart | 8 +- lib/src/database/daos/messages.dao.dart | 30 +- lib/src/database/daos/reactions.dao.dart | 10 +- lib/src/database/daos/stories.dao.dart | 203 ++++++ lib/src/database/daos/stories.dao.g.dart | 34 + .../database/tables/contact_groups.table.dart | 1 + lib/src/database/tables/messages.table.dart | 3 + lib/src/database/twonly.db.dart | 2 + lib/src/database/twonly.db.g.dart | 250 ++++++- .../generated/app_localizations.dart | 138 ++++ .../generated/app_localizations_ar.dart | 93 +++ .../generated/app_localizations_de.dart | 94 +++ .../generated/app_localizations_en.dart | 93 +++ lib/src/localization/translations/ar.arb | 46 +- lib/src/localization/translations/de.arb | 46 +- lib/src/localization/translations/en.arb | 46 +- .../client/generated/messages.pb.dart | 151 +++++ .../client/generated/messages.pbjson.dart | 289 +++++--- lib/src/providers/routing.provider.dart | 18 + .../notifications/native.notifications.dart | 13 +- .../components/recipient_status_row.comp.dart | 60 ++ .../visual/components/story_preview.comp.dart | 98 +++ lib/src/visual/elements/my_chip.element.dart | 68 ++ .../share_image_contact_selection.view.dart | 158 +++-- .../contact_group_shortcut_row.comp.dart | 100 ++- .../first_seen_order.dart | 18 + .../story_target_selector.comp.dart | 219 ++++++ .../views/camera/share_image_editor.view.dart | 5 + .../visual/views/chats/chat_list.view.dart | 70 +- .../group_list_item.comp.dart | 113 +++- .../views/chats/chat_messages.view.dart | 21 + .../response_container.dart | 28 +- .../visual/views/chats/media_viewer.view.dart | 217 ++++-- .../emoji_reactions_row.comp.dart | 22 +- .../own_story_bottom_bar.comp.dart | 73 ++ .../reaction_buttons.comp.dart | 7 + .../story_expiry_timer.dart | 35 + .../story_viewers.bottom_sheet.dart | 173 +++++ .../visual/views/chats/message_info.view.dart | 38 +- .../contact/contact_group_settings.view.dart | 13 + .../contact/select_contact_groups.view.dart | 16 +- lib/src/visual/views/home.view.dart | 48 +- .../views/settings/notification.view.dart | 18 + .../profile/own_story_strip.comp.dart | 148 +++++ .../views/settings/profile/profile.view.dart | 2 + ...fed85d00a117be9b731f6641f0d0bb6ae210d.json | 21 + ...5ad6711dbce8dddffb266d0681123cd910a1b.json | 26 + ...cc4a468ae0e07d5b61ffee329308c6f3e02c5.json | 12 + ...3d57a2501ebf7df4e3daecef0c43e9ec5f0ab.json | 21 + ...2e31190deaea130850e4c286267d053158e7.json} | 4 +- ...5215ff7a2a94f050c20630c69b384e2a59ef7.json | 21 + ...524b9cad10b7855af3dacda23269af2c6f3ee.json | 12 + ...5d870d6d887679ffa0297c67ae0c4bf490f0e.json | 26 + ...c00ae3982ca57a4ad5183e23db672bfc7ca79.json | 21 + ...b26cb00d1aaa27aa23d9d43fbc582972b5978.json | 26 + ...7eb124eaa4aeb981ee3ee37cfa7e2297c1987.json | 26 + ...c0d7e48ca176f3b2e7df9f94559dd240c6eef.json | 26 + ...9db96edbcac9c3f3dac16d8c0945b5451d9e.json} | 6 +- ...e38c197cc6922c101f455ef3d0fd4f644c993.json | 38 ++ ...f86002d9900e14f71dacde26f008af96c18af.json | 12 + ...6ed2c3f4e0cac87d58e18fc27704a4d7688dc.json | 50 ++ ...9fa2586dcb4474d19758b2b41284a105161d9.json | 12 + rust/src/api/messages/incoming.rs | 42 +- rust/src/api/messages/incoming/media.rs | 23 +- rust/src/api/messages/incoming/messages.rs | 10 +- rust/src/api/messages/incoming/story.rs | 354 ++++++++++ .../src/api/messages/incoming/text_message.rs | 12 + rust/src/api/messages/mod.rs | 2 + rust/src/api/proto/client/messages.proto | 12 + rust/src/bridge/api.rs | 13 +- rust/src/bridge/mod.rs | 1 + rust/src/bridge/stories.rs | 46 ++ .../database/app/migrations/0025_stories.sql | 14 + .../migrations/0026_last_text_or_media.sql | 17 + rust/src/database/app/mod.rs | 2 +- rust/src/database/app/tables/groups.rs | 22 + rust/src/error.rs | 3 + rust/src/frb_generated.rs | 562 +++++++++++----- rust/src/services/background.rs | 4 + rust/src/services/direct_media_upload.rs | 380 ++++++++++- rust/src/services/media_upload.rs | 218 +++++- rust/src/services/mediafiles.rs | 12 +- rust/src/services/messages.rs | 1 + rust/src/services/mod.rs | 1 + rust/src/services/notifications.rs | 236 ++++++- rust/src/services/stories.rs | 477 +++++++++++++ rust/src/user_config.rs | 5 + rust/tests/api.rs | 2 + rust/tests/api/stories.rs | 409 ++++++++++++ rust/tests/api/tester.rs | 18 + scripts/fix_adb_connection.sh | 128 ++++ test/drift/contact_groups_dao_test.dart | 1 + test/drift/stories_dao_test.dart | 118 ++++ test/mocks/user_config.dart | 1 + test/services/notification_routing_test.dart | 20 + test/visual/chat_contacts_entry_test.dart | 1 + .../visual/chats/story_expiry_timer_test.dart | 80 +++ .../components/contact_groups_test.dart | 1 + .../visual/components/story_preview_test.dart | 82 +++ .../contact_group_shortcut_row_test.dart | 187 ++++++ .../visual/elements/my_chip_element_test.dart | 70 ++ .../share_image_send_eligibility_test.dart | 62 ++ 116 files changed, 7361 insertions(+), 801 deletions(-) create mode 100644 lib/core/bridge/stories.dart create mode 100644 lib/src/database/daos/stories.dao.dart create mode 100644 lib/src/database/daos/stories.dao.g.dart create mode 100644 lib/src/visual/components/recipient_status_row.comp.dart create mode 100644 lib/src/visual/components/story_preview.comp.dart create mode 100644 lib/src/visual/elements/my_chip.element.dart create mode 100644 lib/src/visual/views/camera/share_image_contact_selection_components/first_seen_order.dart create mode 100644 lib/src/visual/views/camera/share_image_contact_selection_components/story_target_selector.comp.dart create mode 100644 lib/src/visual/views/chats/media_viewer_components/own_story_bottom_bar.comp.dart create mode 100644 lib/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart create mode 100644 lib/src/visual/views/chats/media_viewer_components/story_viewers.bottom_sheet.dart create mode 100644 lib/src/visual/views/settings/profile/own_story_strip.comp.dart create mode 100644 rust/.sqlx/query-0e41d6bc329927e36f9f47028c8fed85d00a117be9b731f6641f0d0bb6ae210d.json create mode 100644 rust/.sqlx/query-0fd03444e490940d3c953bf98935ad6711dbce8dddffb266d0681123cd910a1b.json create mode 100644 rust/.sqlx/query-3166edaa454ae4d5fb69012963ccc4a468ae0e07d5b61ffee329308c6f3e02c5.json create mode 100644 rust/.sqlx/query-4add98e13f0f12e3d852e0b23183d57a2501ebf7df4e3daecef0c43e9ec5f0ab.json rename rust/.sqlx/{query-f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52.json => query-649d1ed9bcc18ad5ea496f3c10992e31190deaea130850e4c286267d053158e7.json} (88%) create mode 100644 rust/.sqlx/query-65fc2abdab1eaba3ac336697a775215ff7a2a94f050c20630c69b384e2a59ef7.json create mode 100644 rust/.sqlx/query-ac056007bc89c8e50751106922a524b9cad10b7855af3dacda23269af2c6f3ee.json create mode 100644 rust/.sqlx/query-b47ac224594691c9a878877f8815d870d6d887679ffa0297c67ae0c4bf490f0e.json create mode 100644 rust/.sqlx/query-b984da6db7f2c197bb89566f2fcc00ae3982ca57a4ad5183e23db672bfc7ca79.json create mode 100644 rust/.sqlx/query-b9b8a9700410dfd75de46b561feb26cb00d1aaa27aa23d9d43fbc582972b5978.json create mode 100644 rust/.sqlx/query-ba5a9b12e344593eda59f627b8b7eb124eaa4aeb981ee3ee37cfa7e2297c1987.json create mode 100644 rust/.sqlx/query-bf36937c8900ffce1ce941ae44bc0d7e48ca176f3b2e7df9f94559dd240c6eef.json rename rust/.sqlx/{query-013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e.json => query-c44fdd0c6ac77ef4b604dd0008f69db96edbcac9c3f3dac16d8c0945b5451d9e.json} (57%) create mode 100644 rust/.sqlx/query-dc47c3260c79b988e6c74af7bc6e38c197cc6922c101f455ef3d0fd4f644c993.json create mode 100644 rust/.sqlx/query-e28362e3911929f66746631d9dff86002d9900e14f71dacde26f008af96c18af.json create mode 100644 rust/.sqlx/query-e2f68ef88181ad5b8f35a9be2d56ed2c3f4e0cac87d58e18fc27704a4d7688dc.json create mode 100644 rust/.sqlx/query-e3684506954963d336412dea8579fa2586dcb4474d19758b2b41284a105161d9.json create mode 100644 rust/src/api/messages/incoming/story.rs create mode 100644 rust/src/bridge/stories.rs create mode 100644 rust/src/database/app/migrations/0025_stories.sql create mode 100644 rust/src/database/app/migrations/0026_last_text_or_media.sql create mode 100644 rust/src/services/stories.rs create mode 100644 rust/tests/api/stories.rs create mode 100755 scripts/fix_adb_connection.sh create mode 100644 test/drift/stories_dao_test.dart create mode 100644 test/visual/chats/story_expiry_timer_test.dart create mode 100644 test/visual/components/story_preview_test.dart create mode 100644 test/visual/contact_group_shortcut_row_test.dart create mode 100644 test/visual/elements/my_chip_element_test.dart create mode 100644 test/visual/share_image_send_eligibility_test.dart diff --git a/ios/NotificationService/NotificationService.entitlements b/ios/NotificationService/NotificationService.entitlements index 035052c7..e32e0256 100644 --- a/ios/NotificationService/NotificationService.entitlements +++ b/ios/NotificationService/NotificationService.entitlements @@ -4,6 +4,8 @@ com.apple.developer.usernotifications.communication + com.apple.developer.usernotifications.filtering + com.apple.security.application-groups group.eu.twonly.runtime diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 53902fc5..b7fa8617 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -107,12 +107,16 @@ final class NotificationService: UNNotificationServiceExtension { WidgetCenter.shared.reloadAllTimelines() } guard let batch = response.batch, !batch.additions.isEmpty else { - // Nothing is waiting on screen for this wake-up — widget-only media is - // the ordinary case — so the deferred work runs while the extension is - // still guaranteed its time, rather than after the content handler has - // made it eligible for termination. + // Nothing is waiting on screen for this wake-up — widget-only media, a + // story the user muted or one that already expired — so the deferred + // work runs while the extension is still guaranteed its time, rather + // than after the content handler has made it eligible for termination. Self.finalizeRuntime() - self.deliverFallback(reason: "notification worker returned no messages") + // The worker ran and decided there is nothing to announce. With the + // notification filtering entitlement, empty content drops the push + // instead of showing its "You got a new message" placeholder. + NSLog("Twonly wake-up had nothing to announce; suppressing the alert") + self.finish(with: UNNotificationContent()) return } self.render(batch: batch, original: request.content) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cfe88b64..8cbf095d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -70,8 +70,6 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - - permission_handler_apple (9.3.0): - - Flutter - PromisesObjC (2.4.0) - rust_lib_twonly (0.0.1) - screen_protector (1.5.1): @@ -89,7 +87,6 @@ DEPENDENCIES: - google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`) - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) - google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`) - screen_protector (from `.symlinks/plugins/screen_protector/ios`) - SwiftProtobuf @@ -128,8 +125,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/google_mlkit_commons/ios" google_mlkit_face_detection: :path: ".symlinks/plugins/google_mlkit_face_detection/ios" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" rust_lib_twonly: :path: ".symlinks/plugins/rust_lib_twonly/ios" screen_protector: @@ -155,7 +150,6 @@ SPEC CHECKSUMS: MLKitFaceDetection: 32549f1e70e6e7731261bf9cea2b74095e2531cb MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 rust_lib_twonly: 6586fdf02e31cd8a3ad9f1ee84796da6bbf289bb screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150 diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 864d7d77..8871c996 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", - "version" : "11.3.1" + "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", + "version" : "11.3.2" } }, { diff --git a/lib/app.dart b/lib/app.dart index 18791223..623f3c83 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -7,6 +7,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_sharing_intent/model/sharing_file.dart' show SharedFile; import 'package:provider/provider.dart'; import 'package:twonly/core/bridge/api.dart' as rust_api; +import 'package:twonly/core/bridge/stories.dart' as stories; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/keyvalue.keys.dart'; @@ -69,6 +70,8 @@ class _AppState extends State with WidgetsBindingObserver { // was suspended, and its Rust change broadcast never reached this // process, so the badge has to be re-read on the way back in. unawaited(NativeNotificationService.refreshBadgeCount()); + // A story may have expired while the app was away. + unawaitedRustCall(stories.purgeExpiredStories(), 'purgeExpiredStories'); } else if (state == AppLifecycleState.paused) { AppState.isAppInBackground = true; unawaited( diff --git a/lib/core/bridge/api.dart b/lib/core/bridge/api.dart index 56a38342..0cb858e5 100644 --- a/lib/core/bridge/api.dart +++ b/lib/core/bridge/api.dart @@ -7,6 +7,7 @@ import '../api/server/prekeys.dart'; import '../frb_generated.dart'; import '../services/media_upload.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'stories.dart'; // These functions are ignored because they are not marked as `pub`: `api_result`, `empty_api_response`, `from_rust_state` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `ApiConfig`, `ServerResult` @@ -739,17 +740,20 @@ class RustApi { onlySendIfNoReceiptsAreOpen: onlySendIfNoReceiptsAreOpen, ); - /// Creates one outgoing message per selected group and starts the upload. + /// Creates one outgoing message per selected group, plus the story rows + /// for `story_audience`, and starts the upload they all share. static Future sendMediaToGroups({ required String mediaId, required List groupIds, Uint8List? additionalMessageData, required bool widgetOnly, + StoryAudience? storyAudience, }) => RustLib.instance.api.crateBridgeApiRustApiSendMediaToGroups( mediaId: mediaId, groupIds: groupIds, additionalMessageData: additionalMessageData, widgetOnly: widgetOnly, + storyAudience: storyAudience, ); static Future sendQueuedMessage({required String receiptId}) => RustLib diff --git a/lib/core/bridge/stories.dart b/lib/core/bridge/stories.dart new file mode 100644 index 00000000..b1b8a68a --- /dev/null +++ b/lib/core/bridge/stories.dart @@ -0,0 +1,48 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// The 1:1 chats a story sent to `audience` would go to right now, one per +/// contact. Together with the chats picked for a direct send, this is who a +/// send reaches. +Future> storyAudienceChats({required StoryAudience audience}) => + RustLib.instance.api.crateBridgeStoriesStoryAudienceChats( + audience: audience, + ); + +/// The most recipients one media send, story rows included, may reach. +int maxMediaRecipients() => + RustLib.instance.api.crateBridgeStoriesMaxMediaRecipients(); + +/// Takes one of the user's own story items down for every recipient. +Future deleteStoryItem({required String mediaId}) => + RustLib.instance.api.crateBridgeStoriesDeleteStoryItem(mediaId: mediaId); + +/// Lets go of every story item older than 24 hours. +Future purgeExpiredStories() => + RustLib.instance.api.crateBridgeStoriesPurgeExpiredStories(); + +class StoryAudience { + final bool all; + final Int64List contactGroupIds; + + const StoryAudience({ + required this.all, + required this.contactGroupIds, + }); + + @override + int get hashCode => all.hashCode ^ contactGroupIds.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StoryAudience && + runtimeType == other.runtimeType && + all == other.all && + contactGroupIds == other.contactGroupIds; +} diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index 3dc9f5ff..5f52fdf0 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -9,6 +9,7 @@ import 'bridge/api.dart'; import 'bridge/callbacks.dart'; import 'bridge/groups.dart'; import 'bridge/logging.dart'; +import 'bridge/stories.dart'; import 'bridge/user_config.dart'; import 'bridge/webxdc.dart'; import 'bridge/wrapper.dart'; @@ -87,7 +88,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1675944024; + int get rustContentHash => -1336092087; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -139,6 +140,8 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeWebxdcDeleteInstance({required String instanceId}); + Future crateBridgeStoriesDeleteStoryItem({required String mediaId}); + Future crateBridgeGroupsFetchGroupState({required String groupId}); Future crateBridgeGroupsFetchGroupStatesForUnjoinedGroups(); @@ -199,6 +202,8 @@ abstract class RustLibApi extends BaseApi { required bool remove, }); + int crateBridgeStoriesMaxMediaRecipients(); + Future crateBridgeWebxdcMembers({required String instanceId}); Future> crateBridgeWebxdcOneTimeInstances({ @@ -208,6 +213,8 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeWebxdcPrepareBundle({required String instanceId}); + Future crateBridgeStoriesPurgeExpiredStories(); + Future crateBridgeLoggingReadLastLogLines({required int lineCount}); Future crateBridgeWebxdcRefreshCatalog(); @@ -499,6 +506,7 @@ abstract class RustLibApi extends BaseApi { required List groupIds, Uint8List? additionalMessageData, required bool widgetOnly, + StoryAudience? storyAudience, }); Future crateBridgeApiRustApiSendQueuedMessage({ @@ -741,6 +749,10 @@ abstract class RustLibApi extends BaseApi { required String requestPath, }); + Future> crateBridgeStoriesStoryAudienceChats({ + required StoryAudience audience, + }); + Future crateBridgeGroupsUpdateChatDeletionTime({ required String groupId, required PlatformInt64 deleteMessagesAfterMilliseconds, @@ -1163,6 +1175,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["instanceId"], ); + @override + Future crateBridgeStoriesDeleteStoryItem({required String mediaId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 12, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeStoriesDeleteStoryItemConstMeta, + argValues: [mediaId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeStoriesDeleteStoryItemConstMeta => + const TaskConstMeta( + debugName: "delete_story_item", + argNames: ["mediaId"], + ); + @override Future crateBridgeGroupsFetchGroupState({required String groupId}) { return handler.executeNormal( @@ -1173,7 +1216,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 12, + funcId: 13, port: port_, ); }, @@ -1203,7 +1246,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 13, + funcId: 14, port: port_, ); }, @@ -1239,7 +1282,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 14, + funcId: 15, port: port_, ); }, @@ -1270,7 +1313,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 15, + funcId: 16, port: port_, ); }, @@ -1303,7 +1346,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 16, + funcId: 17, port: port_, ); }, @@ -1341,7 +1384,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 17, + funcId: 18, port: port_, ); }, @@ -1377,7 +1420,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 18, + funcId: 19, port: port_, ); }, @@ -1420,7 +1463,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 19, + funcId: 20, port: port_, ); }, @@ -1465,7 +1508,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 20, + funcId: 21, port: port_, ); }, @@ -1502,7 +1545,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 21, + funcId: 22, port: port_, ); }, @@ -1535,7 +1578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 22, + funcId: 23, port: port_, ); }, @@ -1568,7 +1611,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 23, + funcId: 24, port: port_, ); }, @@ -1598,7 +1641,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 24, + funcId: 25, port: port_, ); }, @@ -1628,7 +1671,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 25, + funcId: 26, port: port_, ); }, @@ -1665,7 +1708,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 26, + funcId: 27, port: port_, ); }, @@ -1686,6 +1729,31 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["groupId", "contactId", "remove"], ); + @override + int crateBridgeStoriesMaxMediaRecipients() { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_32, + decodeErrorData: null, + ), + constMeta: kCrateBridgeStoriesMaxMediaRecipientsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeStoriesMaxMediaRecipientsConstMeta => + const TaskConstMeta( + debugName: "max_media_recipients", + argNames: [], + ); + @override Future crateBridgeWebxdcMembers({required String instanceId}) { return handler.executeNormal( @@ -1696,7 +1764,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 27, + funcId: 29, port: port_, ); }, @@ -1730,7 +1798,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 28, + funcId: 30, port: port_, ); }, @@ -1761,7 +1829,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 29, + funcId: 31, port: port_, ); }, @@ -1782,6 +1850,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["instanceId"], ); + @override + Future crateBridgeStoriesPurgeExpiredStories() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 32, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeStoriesPurgeExpiredStoriesConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeStoriesPurgeExpiredStoriesConstMeta => + const TaskConstMeta( + debugName: "purge_expired_stories", + argNames: [], + ); + @override Future crateBridgeLoggingReadLastLogLines({required int lineCount}) { return handler.executeNormal( @@ -1792,7 +1890,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 30, + funcId: 33, port: port_, ); }, @@ -1822,7 +1920,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 31, + funcId: 34, port: port_, ); }, @@ -1857,7 +1955,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 32, + funcId: 35, port: port_, ); }, @@ -1888,7 +1986,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 33, + funcId: 36, port: port_, ); }, @@ -1919,7 +2017,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 34, + funcId: 37, port: port_, ); }, @@ -1952,7 +2050,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 35, + funcId: 38, port: port_, ); }, @@ -1982,7 +2080,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 36, + funcId: 39, port: port_, ); }, @@ -2010,7 +2108,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(protocol, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 40)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2038,7 +2136,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 38, + funcId: 41, port: port_, ); }, @@ -2070,7 +2168,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_i_64(contactId, serializer); sse_encode_i_64(profileCounter, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 39)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 42)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2099,7 +2197,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 40, + funcId: 43, port: port_, ); }, @@ -2129,7 +2227,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 44, port: port_, ); }, @@ -2170,7 +2268,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 45, port: port_, ); }, @@ -2211,7 +2309,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 46, port: port_, ); }, @@ -2246,7 +2344,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 47, port: port_, ); }, @@ -2278,7 +2376,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 48, port: port_, ); }, @@ -2310,7 +2408,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 49, port: port_, ); }, @@ -2340,7 +2438,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 50, port: port_, ); }, @@ -2370,7 +2468,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 51, port: port_, ); }, @@ -2403,7 +2501,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 52, port: port_, ); }, @@ -2434,7 +2532,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 50, + funcId: 53, port: port_, ); }, @@ -2464,7 +2562,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_prim_u_8_loose(avatarSvgCompressed, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 51)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 54)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2492,7 +2590,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 52, + funcId: 55, port: port_, ); }, @@ -2523,7 +2621,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 53, + funcId: 56, port: port_, ); }, @@ -2556,7 +2654,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 54, + funcId: 57, port: port_, ); }, @@ -2586,7 +2684,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 55, + funcId: 58, port: port_, ); }, @@ -2617,7 +2715,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 56, + funcId: 59, port: port_, ); }, @@ -2648,7 +2746,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 57, + funcId: 60, port: port_, ); }, @@ -2678,7 +2776,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 58, + funcId: 61, port: port_, ); }, @@ -2711,7 +2809,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 59, + funcId: 62, port: port_, ); }, @@ -2746,7 +2844,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 60, + funcId: 63, port: port_, ); }, @@ -2779,7 +2877,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 61, + funcId: 64, port: port_, ); }, @@ -2811,7 +2909,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 62, + funcId: 65, port: port_, ); }, @@ -2841,7 +2939,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 63, + funcId: 66, port: port_, ); }, @@ -2876,7 +2974,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 64, + funcId: 67, port: port_, ); }, @@ -2906,7 +3004,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 65, + funcId: 68, port: port_, ); }, @@ -2936,7 +3034,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 66, + funcId: 69, port: port_, ); }, @@ -2966,7 +3064,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 67, + funcId: 70, port: port_, ); }, @@ -3007,7 +3105,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 68, + funcId: 71, port: port_, ); }, @@ -3054,7 +3152,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 69, + funcId: 72, port: port_, ); }, @@ -3087,7 +3185,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 70, + funcId: 73, port: port_, ); }, @@ -3120,7 +3218,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 71, + funcId: 74, port: port_, ); }, @@ -3150,7 +3248,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 72, + funcId: 75, port: port_, ); }, @@ -3190,7 +3288,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 73, + funcId: 76, port: port_, ); }, @@ -3223,7 +3321,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 74, + funcId: 77, port: port_, ); }, @@ -3262,7 +3360,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 75, + funcId: 78, port: port_, ); }, @@ -3298,7 +3396,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 76, + funcId: 79, port: port_, ); }, @@ -3333,7 +3431,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 77, + funcId: 80, port: port_, ); }, @@ -3375,7 +3473,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 78, + funcId: 81, port: port_, ); }, @@ -3417,7 +3515,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 79, + funcId: 82, port: port_, ); }, @@ -3447,7 +3545,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 80, + funcId: 83, port: port_, ); }, @@ -3480,7 +3578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 81, + funcId: 84, port: port_, ); }, @@ -3515,7 +3613,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 82, + funcId: 85, port: port_, ); }, @@ -3545,7 +3643,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 83, + funcId: 86, port: port_, ); }, @@ -3580,7 +3678,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 84, + funcId: 87, port: port_, ); }, @@ -3610,7 +3708,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 85, + funcId: 88, port: port_, ); }, @@ -3643,7 +3741,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 86, + funcId: 89, port: port_, ); }, @@ -3673,7 +3771,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 87, + funcId: 90, port: port_, ); }, @@ -3703,7 +3801,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 88, + funcId: 91, port: port_, ); }, @@ -3733,7 +3831,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 89, + funcId: 92, port: port_, ); }, @@ -3763,7 +3861,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 90, + funcId: 93, port: port_, ); }, @@ -3802,7 +3900,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 91, + funcId: 94, port: port_, ); }, @@ -3837,7 +3935,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 92, + funcId: 95, port: port_, ); }, @@ -3876,7 +3974,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 93, + funcId: 96, port: port_, ); }, @@ -3918,7 +4016,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 94, + funcId: 97, port: port_, ); }, @@ -3949,7 +4047,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 95, + funcId: 98, port: port_, ); }, @@ -3982,7 +4080,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 96, + funcId: 99, port: port_, ); }, @@ -4015,7 +4113,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 97, + funcId: 100, port: port_, ); }, @@ -4050,7 +4148,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 98, + funcId: 101, port: port_, ); }, @@ -4083,7 +4181,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 99, + funcId: 102, port: port_, ); }, @@ -4116,7 +4214,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 100, + funcId: 103, port: port_, ); }, @@ -4149,7 +4247,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 101, + funcId: 104, port: port_, ); }, @@ -4186,7 +4284,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 102, + funcId: 105, port: port_, ); }, @@ -4216,7 +4314,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 103, + funcId: 106, port: port_, ); }, @@ -4246,7 +4344,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 104, + funcId: 107, port: port_, ); }, @@ -4276,7 +4374,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 105, + funcId: 108, port: port_, ); }, @@ -4309,7 +4407,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 106, + funcId: 109, port: port_, ); }, @@ -4340,7 +4438,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 107, + funcId: 110, port: port_, ); }, @@ -4373,7 +4471,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 108, + funcId: 111, port: port_, ); }, @@ -4419,7 +4517,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 109, + funcId: 112, port: port_, ); }, @@ -4475,7 +4573,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 110, + funcId: 113, port: port_, ); }, @@ -4508,6 +4606,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { required List groupIds, Uint8List? additionalMessageData, required bool widgetOnly, + StoryAudience? storyAudience, }) { return handler.executeNormal( NormalTask( @@ -4520,10 +4619,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer, ); sse_encode_bool(widgetOnly, serializer); + sse_encode_opt_box_autoadd_story_audience(storyAudience, serializer); pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 111, + funcId: 114, port: port_, ); }, @@ -4532,7 +4632,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_AnyhowException, ), constMeta: kCrateBridgeApiRustApiSendMediaToGroupsConstMeta, - argValues: [mediaId, groupIds, additionalMessageData, widgetOnly], + argValues: [ + mediaId, + groupIds, + additionalMessageData, + widgetOnly, + storyAudience, + ], apiImpl: this, ), ); @@ -4546,6 +4652,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { "groupIds", "additionalMessageData", "widgetOnly", + "storyAudience", ], ); @@ -4561,7 +4668,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 112, + funcId: 115, port: port_, ); }, @@ -4596,7 +4703,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 113, + funcId: 116, port: port_, ); }, @@ -4631,7 +4738,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 114, + funcId: 117, port: port_, ); }, @@ -4664,7 +4771,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 115, + funcId: 118, port: port_, ); }, @@ -4701,7 +4808,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 116, + funcId: 119, port: port_, ); }, @@ -4732,7 +4839,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 117, + funcId: 120, port: port_, ); }, @@ -4770,7 +4877,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 118, + funcId: 121, port: port_, ); }, @@ -4805,7 +4912,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 119, + funcId: 122, port: port_, ); }, @@ -4844,7 +4951,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 120, + funcId: 123, port: port_, ); }, @@ -4877,7 +4984,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 121, + funcId: 124, port: port_, ); }, @@ -4908,7 +5015,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 122, + funcId: 125, port: port_, ); }, @@ -4943,7 +5050,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 123, + funcId: 126, port: port_, ); }, @@ -4973,7 +5080,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 124, + funcId: 127, port: port_, ); }, @@ -5006,7 +5113,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 125, + funcId: 128, port: port_, ); }, @@ -5041,7 +5148,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 126, + funcId: 129, port: port_, ); }, @@ -5074,7 +5181,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 127, + funcId: 130, port: port_, ); }, @@ -5105,7 +5212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 128, + funcId: 131, port: port_, ); }, @@ -5142,7 +5249,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 129, + funcId: 132, port: port_, ); }, @@ -5194,7 +5301,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 130, + funcId: 133, port: port_, ); }, @@ -5247,7 +5354,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 131, + funcId: 134, port: port_, ); }, @@ -5287,7 +5394,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 132, + funcId: 135, port: port_, ); }, @@ -5320,7 +5427,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 133, + funcId: 136, port: port_, ); }, @@ -5353,7 +5460,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 134, + funcId: 137, port: port_, ); }, @@ -5390,7 +5497,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 135, + funcId: 138, port: port_, ); }, @@ -5422,7 +5529,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 136, + funcId: 139, port: port_, ); }, @@ -5455,7 +5562,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 137, + funcId: 140, port: port_, ); }, @@ -5490,7 +5597,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 138, + funcId: 141, port: port_, ); }, @@ -5522,7 +5629,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 139, + funcId: 142, port: port_, ); }, @@ -5560,7 +5667,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 140, + funcId: 143, port: port_, ); }, @@ -5593,7 +5700,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 141, + funcId: 144, port: port_, ); }, @@ -5631,7 +5738,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 142, + funcId: 145, port: port_, ); }, @@ -5668,7 +5775,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 143, + funcId: 146, port: port_, ); }, @@ -5705,7 +5812,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 144, + funcId: 147, port: port_, ); }, @@ -5743,7 +5850,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 145, + funcId: 148, port: port_, ); }, @@ -5781,7 +5888,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 146, + funcId: 149, port: port_, ); }, @@ -5814,7 +5921,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 147, + funcId: 150, port: port_, ); }, @@ -5846,7 +5953,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 148, + funcId: 151, port: port_, ); }, @@ -5881,7 +5988,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 149, + funcId: 152, port: port_, ); }, @@ -5918,7 +6025,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 150, + funcId: 153, port: port_, ); }, @@ -5951,7 +6058,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 151, + funcId: 154, port: port_, ); }, @@ -5983,7 +6090,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 152, + funcId: 155, port: port_, ); }, @@ -6018,7 +6125,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 153, + funcId: 156, port: port_, ); }, @@ -6057,7 +6164,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 154, + funcId: 157, port: port_, ); }, @@ -6094,7 +6201,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 155, + funcId: 158, port: port_, ); }, @@ -6124,7 +6231,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 156, + funcId: 159, port: port_, ); }, @@ -6156,7 +6263,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 157, + funcId: 160, port: port_, ); }, @@ -6191,7 +6298,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 158, + funcId: 161, port: port_, ); }, @@ -6226,7 +6333,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 159, + funcId: 162, port: port_, ); }, @@ -6257,7 +6364,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 160, + funcId: 163, port: port_, ); }, @@ -6295,7 +6402,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 161, + funcId: 164, port: port_, ); }, @@ -6330,7 +6437,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 162, + funcId: 165, port: port_, ); }, @@ -6369,7 +6476,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 163, + funcId: 166, port: port_, ); }, @@ -6404,7 +6511,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 164, + funcId: 167, port: port_, ); }, @@ -6449,7 +6556,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 165, + funcId: 168, port: port_, ); }, @@ -6492,7 +6599,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 166, + funcId: 169, port: port_, ); }, @@ -6512,6 +6619,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["instanceId", "requestPath"], ); + @override + Future> crateBridgeStoriesStoryAudienceChats({ + required StoryAudience audience, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_story_audience(audience, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 170, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeStoriesStoryAudienceChatsConstMeta, + argValues: [audience], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeStoriesStoryAudienceChatsConstMeta => + const TaskConstMeta( + debugName: "story_audience_chats", + argNames: ["audience"], + ); + @override Future crateBridgeGroupsUpdateChatDeletionTime({ required String groupId, @@ -6526,7 +6666,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 167, + funcId: 171, port: port_, ); }, @@ -6561,7 +6701,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 168, + funcId: 172, port: port_, ); }, @@ -6596,7 +6736,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 169, + funcId: 173, port: port_, ); }, @@ -6629,7 +6769,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 170, + funcId: 174, )!; }, codec: SseCodec( @@ -6669,7 +6809,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 171, + funcId: 175, port: port_, ); }, @@ -6714,7 +6854,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 172, + funcId: 176, port: port_, ); }, @@ -6744,7 +6884,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 173, + funcId: 177, port: port_, ); }, @@ -6777,7 +6917,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 174, + funcId: 178, port: port_, ); }, @@ -6812,7 +6952,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 175, + funcId: 179, port: port_, ); }, @@ -6851,7 +6991,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 176, + funcId: 180, )!; }, codec: SseCodec( @@ -7130,6 +7270,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dco_decode_passwordless_recovery_config(raw); } + @protected + StoryAudience dco_decode_box_autoadd_story_audience(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_story_audience(raw); + } + @protected TwonlySafeBackup dco_decode_box_autoadd_twonly_safe_backup(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -7597,6 +7743,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { : dco_decode_box_autoadd_passwordless_recovery_config(raw); } + @protected + StoryAudience? dco_decode_opt_box_autoadd_story_audience(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_box_autoadd_story_audience(raw); + } + @protected TwonlySafeBackup? dco_decode_opt_box_autoadd_twonly_safe_backup(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -7844,6 +7996,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + StoryAudience dco_decode_story_audience(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return StoryAudience( + all: dco_decode_bool(arr[0]), + contactGroupIds: dco_decode_list_prim_i_64_strict(arr[1]), + ); + } + @protected ThemeMode dco_decode_theme_mode(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -7905,8 +8069,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 != 63) - throw Exception('unexpected arr length: expect 63 but see ${arr.length}'); + if (arr.length != 64) + throw Exception('unexpected arr length: expect 64 but see ${arr.length}'); return UserConfig( userId: dco_decode_i_64(arr[0]), username: dco_decode_String(arr[1]), @@ -7940,42 +8104,43 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { storeLocationInMemories: dco_decode_bool(arr[29]), autoStoreAllSendUnlimitedMediaFiles: dco_decode_bool(arr[30]), typingIndicators: dco_decode_bool(arr[31]), - showRestoreFlame: dco_decode_bool(arr[32]), - myBestFriendGroupId: dco_decode_opt_String(arr[33]), + storyNotifications: dco_decode_bool(arr[32]), + showRestoreFlame: dco_decode_bool(arr[33]), + myBestFriendGroupId: dco_decode_opt_String(arr[34]), signalLastSignedPreKeyUpdated: dco_decode_opt_box_autoadd_Chrono_Utc( - arr[34], - ), - signalLastPqcPreKeysUploaded: dco_decode_opt_box_autoadd_Chrono_Utc( arr[35], ), - allowErrorTrackingViaSentry: dco_decode_bool(arr[36]), - screenLockEnabled: dco_decode_bool(arr[37]), - isCloudBackupEnabled: dco_decode_bool(arr[38]), - isUserDiscoveryEnabled: dco_decode_bool(arr[39]), - requiredSendImages: dco_decode_i_64(arr[40]), - userDiscoveryThreshold: dco_decode_u_8(arr[41]), - userDiscoveryRequiresManualApproval: dco_decode_bool(arr[42]), - userDiscoverySharePromotion: dco_decode_bool(arr[43]), - userDiscoveryInitializationError: dco_decode_bool(arr[44]), - askForFriendPromotions: dco_decode_bool(arr[45]), - currentPreKeyIndexStart: dco_decode_i_64(arr[46]), - currentSignedPreKeyIndexStart: dco_decode_i_64(arr[47]), - lastChangeLogHash: dco_decode_opt_list_prim_u_8_strict(arr[48]), - hideChangeLog: dco_decode_bool(arr[49]), - hideMemoriesBackupPromo: dco_decode_bool(arr[50]), - hideWidgetShareExplainer: dco_decode_bool(arr[51]), - updateFcmToken: dco_decode_bool(arr[52]), - canUseLoginTokenForAuth: dco_decode_bool(arr[53]), - twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[54]), - isBackupEnabled: dco_decode_bool(arr[55]), + signalLastPqcPreKeysUploaded: dco_decode_opt_box_autoadd_Chrono_Utc( + arr[36], + ), + allowErrorTrackingViaSentry: dco_decode_bool(arr[37]), + screenLockEnabled: dco_decode_bool(arr[38]), + isCloudBackupEnabled: dco_decode_bool(arr[39]), + isUserDiscoveryEnabled: dco_decode_bool(arr[40]), + requiredSendImages: dco_decode_i_64(arr[41]), + userDiscoveryThreshold: dco_decode_u_8(arr[42]), + userDiscoveryRequiresManualApproval: dco_decode_bool(arr[43]), + userDiscoverySharePromotion: dco_decode_bool(arr[44]), + userDiscoveryInitializationError: dco_decode_bool(arr[45]), + askForFriendPromotions: dco_decode_bool(arr[46]), + currentPreKeyIndexStart: dco_decode_i_64(arr[47]), + currentSignedPreKeyIndexStart: dco_decode_i_64(arr[48]), + lastChangeLogHash: dco_decode_opt_list_prim_u_8_strict(arr[49]), + hideChangeLog: dco_decode_bool(arr[50]), + hideMemoriesBackupPromo: dco_decode_bool(arr[51]), + hideWidgetShareExplainer: dco_decode_bool(arr[52]), + updateFcmToken: dco_decode_bool(arr[53]), + canUseLoginTokenForAuth: dco_decode_bool(arr[54]), + twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[55]), + isBackupEnabled: dco_decode_bool(arr[56]), passwordLessRecovery: - dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[56]), - fcmToken: dco_decode_opt_String(arr[57]), - lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[58]), - lastServerMessageAt: dco_decode_opt_box_autoadd_i_64(arr[59]), - currentSetupPage: dco_decode_opt_String(arr[60]), - skipSetupPages: dco_decode_bool(arr[61]), - hasZoomed: dco_decode_bool(arr[62]), + dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[57]), + fcmToken: dco_decode_opt_String(arr[58]), + lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[59]), + lastServerMessageAt: dco_decode_opt_box_autoadd_i_64(arr[60]), + currentSetupPage: dco_decode_opt_String(arr[61]), + skipSetupPages: dco_decode_bool(arr[62]), + hasZoomed: dco_decode_bool(arr[63]), ); } @@ -8264,6 +8429,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_passwordless_recovery_config(deserializer)); } + @protected + StoryAudience sse_decode_box_autoadd_story_audience( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_story_audience(deserializer)); + } + @protected TwonlySafeBackup sse_decode_box_autoadd_twonly_safe_backup( SseDeserializer deserializer, @@ -8897,6 +9070,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + StoryAudience? sse_decode_opt_box_autoadd_story_audience( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_story_audience(deserializer)); + } else { + return null; + } + } + @protected TwonlySafeBackup? sse_decode_opt_box_autoadd_twonly_safe_backup( SseDeserializer deserializer, @@ -9157,6 +9343,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + StoryAudience sse_decode_story_audience(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_all = sse_decode_bool(deserializer); + var var_contactGroupIds = sse_decode_list_prim_i_64_strict(deserializer); + return StoryAudience(all: var_all, contactGroupIds: var_contactGroupIds); + } + @protected ThemeMode sse_decode_theme_mode(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -9260,6 +9454,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_storeLocationInMemories = sse_decode_bool(deserializer); var var_autoStoreAllSendUnlimitedMediaFiles = sse_decode_bool(deserializer); var var_typingIndicators = sse_decode_bool(deserializer); + var var_storyNotifications = sse_decode_bool(deserializer); var var_showRestoreFlame = sse_decode_bool(deserializer); var var_myBestFriendGroupId = sse_decode_opt_String(deserializer); var var_signalLastSignedPreKeyUpdated = @@ -9333,6 +9528,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { autoStoreAllSendUnlimitedMediaFiles: var_autoStoreAllSendUnlimitedMediaFiles, typingIndicators: var_typingIndicators, + storyNotifications: var_storyNotifications, showRestoreFlame: var_showRestoreFlame, myBestFriendGroupId: var_myBestFriendGroupId, signalLastSignedPreKeyUpdated: var_signalLastSignedPreKeyUpdated, @@ -9741,6 +9937,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_passwordless_recovery_config(self, serializer); } + @protected + void sse_encode_box_autoadd_story_audience( + StoryAudience self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_story_audience(self, serializer); + } + @protected void sse_encode_box_autoadd_twonly_safe_backup( TwonlySafeBackup self, @@ -10300,6 +10505,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_opt_box_autoadd_story_audience( + StoryAudience? self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_story_audience(self, serializer); + } + } + @protected void sse_encode_opt_box_autoadd_twonly_safe_backup( TwonlySafeBackup? self, @@ -10532,6 +10750,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_list_prim_u_8_strict(self.blobValue, serializer); } + @protected + void sse_encode_story_audience(StoryAudience self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_bool(self.all, serializer); + sse_encode_list_prim_i_64_strict(self.contactGroupIds, serializer); + } + @protected void sse_encode_theme_mode(ThemeMode self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -10624,6 +10849,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bool(self.storeLocationInMemories, serializer); sse_encode_bool(self.autoStoreAllSendUnlimitedMediaFiles, serializer); sse_encode_bool(self.typingIndicators, serializer); + sse_encode_bool(self.storyNotifications, serializer); sse_encode_bool(self.showRestoreFlame, serializer); sse_encode_opt_String(self.myBestFriendGroupId, serializer); sse_encode_opt_box_autoadd_Chrono_Utc( diff --git a/lib/core/frb_generated.io.dart b/lib/core/frb_generated.io.dart index dfa57ed2..50424888 100644 --- a/lib/core/frb_generated.io.dart +++ b/lib/core/frb_generated.io.dart @@ -9,6 +9,7 @@ import 'bridge/api.dart'; import 'bridge/callbacks.dart'; import 'bridge/groups.dart'; import 'bridge/logging.dart'; +import 'bridge/stories.dart'; import 'bridge/user_config.dart'; import 'bridge/webxdc.dart'; import 'bridge/wrapper.dart'; @@ -120,6 +121,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { PasswordlessRecoveryConfig dco_decode_box_autoadd_passwordless_recovery_config(dynamic raw); + @protected + StoryAudience dco_decode_box_autoadd_story_audience(dynamic raw); + @protected TwonlySafeBackup dco_decode_box_autoadd_twonly_safe_backup(dynamic raw); @@ -298,6 +302,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { PasswordlessRecoveryConfig? dco_decode_opt_box_autoadd_passwordless_recovery_config(dynamic raw); + @protected + StoryAudience? dco_decode_opt_box_autoadd_story_audience(dynamic raw); + @protected TwonlySafeBackup? dco_decode_opt_box_autoadd_twonly_safe_backup(dynamic raw); @@ -376,6 +383,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected SqlValue dco_decode_sql_value(dynamic raw); + @protected + StoryAudience dco_decode_story_audience(dynamic raw); + @protected ThemeMode dco_decode_theme_mode(dynamic raw); @@ -521,6 +531,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + StoryAudience sse_decode_box_autoadd_story_audience( + SseDeserializer deserializer, + ); + @protected TwonlySafeBackup sse_decode_box_autoadd_twonly_safe_backup( SseDeserializer deserializer, @@ -733,6 +748,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + StoryAudience? sse_decode_opt_box_autoadd_story_audience( + SseDeserializer deserializer, + ); + @protected TwonlySafeBackup? sse_decode_opt_box_autoadd_twonly_safe_backup( SseDeserializer deserializer, @@ -827,6 +847,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected SqlValue sse_decode_sql_value(SseDeserializer deserializer); + @protected + StoryAudience sse_decode_story_audience(SseDeserializer deserializer); + @protected ThemeMode sse_decode_theme_mode(SseDeserializer deserializer); @@ -1013,6 +1036,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_story_audience( + StoryAudience self, + SseSerializer serializer, + ); + @protected void sse_encode_box_autoadd_twonly_safe_backup( TwonlySafeBackup self, @@ -1280,6 +1309,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_opt_box_autoadd_story_audience( + StoryAudience? self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_box_autoadd_twonly_safe_backup( TwonlySafeBackup? self, @@ -1397,6 +1432,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_sql_value(SqlValue self, SseSerializer serializer); + @protected + void sse_encode_story_audience(StoryAudience self, SseSerializer serializer); + @protected void sse_encode_theme_mode(ThemeMode self, SseSerializer serializer); diff --git a/lib/core/frb_generated.web.dart b/lib/core/frb_generated.web.dart index 35baaf77..65a46b6e 100644 --- a/lib/core/frb_generated.web.dart +++ b/lib/core/frb_generated.web.dart @@ -12,6 +12,7 @@ import 'bridge/api.dart'; import 'bridge/callbacks.dart'; import 'bridge/groups.dart'; import 'bridge/logging.dart'; +import 'bridge/stories.dart'; import 'bridge/user_config.dart'; import 'bridge/webxdc.dart'; import 'bridge/wrapper.dart'; @@ -122,6 +123,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { PasswordlessRecoveryConfig dco_decode_box_autoadd_passwordless_recovery_config(dynamic raw); + @protected + StoryAudience dco_decode_box_autoadd_story_audience(dynamic raw); + @protected TwonlySafeBackup dco_decode_box_autoadd_twonly_safe_backup(dynamic raw); @@ -300,6 +304,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { PasswordlessRecoveryConfig? dco_decode_opt_box_autoadd_passwordless_recovery_config(dynamic raw); + @protected + StoryAudience? dco_decode_opt_box_autoadd_story_audience(dynamic raw); + @protected TwonlySafeBackup? dco_decode_opt_box_autoadd_twonly_safe_backup(dynamic raw); @@ -378,6 +385,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected SqlValue dco_decode_sql_value(dynamic raw); + @protected + StoryAudience dco_decode_story_audience(dynamic raw); + @protected ThemeMode dco_decode_theme_mode(dynamic raw); @@ -523,6 +533,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + StoryAudience sse_decode_box_autoadd_story_audience( + SseDeserializer deserializer, + ); + @protected TwonlySafeBackup sse_decode_box_autoadd_twonly_safe_backup( SseDeserializer deserializer, @@ -735,6 +750,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + StoryAudience? sse_decode_opt_box_autoadd_story_audience( + SseDeserializer deserializer, + ); + @protected TwonlySafeBackup? sse_decode_opt_box_autoadd_twonly_safe_backup( SseDeserializer deserializer, @@ -829,6 +849,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected SqlValue sse_decode_sql_value(SseDeserializer deserializer); + @protected + StoryAudience sse_decode_story_audience(SseDeserializer deserializer); + @protected ThemeMode sse_decode_theme_mode(SseDeserializer deserializer); @@ -1015,6 +1038,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_story_audience( + StoryAudience self, + SseSerializer serializer, + ); + @protected void sse_encode_box_autoadd_twonly_safe_backup( TwonlySafeBackup self, @@ -1282,6 +1311,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_opt_box_autoadd_story_audience( + StoryAudience? self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_box_autoadd_twonly_safe_backup( TwonlySafeBackup? self, @@ -1399,6 +1434,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_sql_value(SqlValue self, SseSerializer serializer); + @protected + void sse_encode_story_audience(StoryAudience self, SseSerializer serializer); + @protected void sse_encode_theme_mode(ThemeMode self, SseSerializer serializer); diff --git a/lib/core/user_config.dart b/lib/core/user_config.dart index a8cdf241..3c864cbc 100644 --- a/lib/core/user_config.dart +++ b/lib/core/user_config.dart @@ -140,6 +140,10 @@ class UserConfig { bool storeLocationInMemories; bool autoStoreAllSendUnlimitedMediaFiles; bool typingIndicators; + + /// Announce a contact's first story in 24 hours. Checked only on this + /// device, where the notification is created; senders always wake. + bool storyNotifications; bool showRestoreFlame; /// Unused: the best friend is derived from `total_media_counter` whenever @@ -217,6 +221,7 @@ class UserConfig { required this.storeLocationInMemories, required this.autoStoreAllSendUnlimitedMediaFiles, required this.typingIndicators, + required this.storyNotifications, required this.showRestoreFlame, this.myBestFriendGroupId, this.signalLastSignedPreKeyUpdated, @@ -284,6 +289,7 @@ class UserConfig { storeLocationInMemories.hashCode ^ autoStoreAllSendUnlimitedMediaFiles.hashCode ^ typingIndicators.hashCode ^ + storyNotifications.hashCode ^ showRestoreFlame.hashCode ^ myBestFriendGroupId.hashCode ^ signalLastSignedPreKeyUpdated.hashCode ^ @@ -356,6 +362,7 @@ class UserConfig { autoStoreAllSendUnlimitedMediaFiles == other.autoStoreAllSendUnlimitedMediaFiles && typingIndicators == other.typingIndicators && + storyNotifications == other.storyNotifications && showRestoreFlame == other.showRestoreFlame && myBestFriendGroupId == other.myBestFriendGroupId && signalLastSignedPreKeyUpdated == diff --git a/lib/main.dart b/lib/main.dart index 1e8dede2..d595c1ef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:twonly/app.dart'; import 'package:twonly/core/bridge.dart' as bridge; +import 'package:twonly/core/bridge/stories.dart' as stories; import 'package:twonly/core/bridge/wrapper/app_database.dart'; import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/core/frb_generated.dart'; @@ -290,6 +291,7 @@ Future postStartupTasks() async { unawaited(MemoriesService.prewarmCache()); // 1. Immediate background cleanup (Non-blocking for UI) + unawaitedRustCall(stories.purgeExpiredStories(), 'purgeExpiredStories'); await twonlyDB.messagesDao.purgeMessageTable(); unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts()); unawaited(MediaFileService.purgeTempFolder()); diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart index e4a91c1a..a4cadfe0 100644 --- a/lib/src/constants/routes.keys.dart +++ b/lib/src/constants/routes.keys.dart @@ -9,6 +9,12 @@ class Routes { static String chatsMessages(String groupId) => '/chats/messages/$groupId'; + /// A contact's story. Pass a media id as `extra` to start at that item. + static String chatsStory(int contactId) => '/chats/story/$contactId'; + + /// The user's own story. Pass a media id as `extra` to start at that item. + static const String chatsOwnStory = '/chats/own_story'; + static String groupCreateSelectMember(String? groupId) => '/group/create/select_member${groupId == null ? '' : '/$groupId'}'; diff --git a/lib/src/database/daos/contact_groups.dao.dart b/lib/src/database/daos/contact_groups.dao.dart index fef78b26..864855d6 100644 --- a/lib/src/database/daos/contact_groups.dao.dart +++ b/lib/src/database/daos/contact_groups.dao.dart @@ -22,6 +22,17 @@ class ContactGroupsDao extends DatabaseAccessor .watch(); } + /// The contact groups a story can be sent to, most used first. + Stream> watchStoryContactGroups() { + return (select(contactGroups) + ..where((t) => t.shareStories.equals(true)) + ..orderBy([ + (t) => OrderingTerm.desc(t.usageCounter), + (t) => OrderingTerm.asc(t.name), + ])) + .watch(); + } + Stream> watchVisibleGroupsForUser(int userId) { final query = select(contactGroupMembers).join([ @@ -132,6 +143,7 @@ class ContactGroupsDao extends DatabaseAccessor required int backgroundColor, required bool showAsShortcut, required bool showAsLabel, + required bool shareStories, String? emoji, }) { return into(contactGroups).insert( @@ -142,6 +154,7 @@ class ContactGroupsDao extends DatabaseAccessor backgroundColor: backgroundColor, showAsShortcut: Value(showAsShortcut), showAsLabel: Value(showAsLabel), + shareStories: Value(shareStories), ), ); } @@ -153,6 +166,7 @@ class ContactGroupsDao extends DatabaseAccessor required int backgroundColor, required bool showAsShortcut, required bool showAsLabel, + required bool shareStories, String? emoji, }) { return (update(contactGroups)..where((t) => t.id.equals(id))) @@ -164,6 +178,7 @@ class ContactGroupsDao extends DatabaseAccessor backgroundColor: Value(backgroundColor), showAsShortcut: Value(showAsShortcut), showAsLabel: Value(showAsLabel), + shareStories: Value(shareStories), ), ) .then((rows) => rows > 0); diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index d603a677..10ddfb9c 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -171,7 +171,11 @@ class MediaFilesDao extends DatabaseAccessor db.messages.mediaId.equalsExp(mediaFiles.mediaId), useColumns: false, ), - ])..where(db.messages.groupId.equals(groupId)); + ])..where( + db.messages.groupId.equals(groupId) & + (db.messages.isStory.equals(false) | + db.messages.mediaStored.equals(true)), + ); return query.map((row) => row.readTable(mediaFiles)).watch(); } @@ -226,6 +230,7 @@ class MediaFilesDao extends DatabaseAccessor ) AS message_rank FROM messages WHERE messages.media_id IS NOT NULL + AND (messages.is_story = 0 OR messages.media_stored = 1) ) SELECT DISTINCT media_files.* FROM media_files @@ -254,6 +259,7 @@ class MediaFilesDao extends DatabaseAccessor mediaFiles.storedFileHash.equals(hash) & db.messages.senderId.equals(senderId) & db.messages.isWidgetMedia.equals(false) & + db.messages.isStory.equals(false) & db.messages.openedAt.isNull(), ); diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index e79ce35d..6cfe34c9 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -31,6 +31,11 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { // ignore: matching_super_parameters MessagesDao(super.db); + /// A story row stays out of the chat until somebody saves it; from then on + /// it is an ordinary stored media message there. + Expression get _visibleInChat => + messages.isStory.equals(false) | messages.mediaStored.equals(true); + Stream> watchMessageNotOpened(String groupId) { final query = select(messages).join([ @@ -42,6 +47,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ..where( messages.openedAt.isNull() & messages.isWidgetMedia.equals(false) & + _visibleInChat & messages.groupId.equals(groupId) & messages.isDeletedFromSender.equals(false) & (messages.mediaId.isNull() | @@ -65,6 +71,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ..where( messages.openedAt.isNull() & messages.isWidgetMedia.equals(false) & + _visibleInChat & messages.isDeletedFromSender.equals(false) & (messages.mediaId.isNull() | mediaFiles.downloadState.isNull() | @@ -92,6 +99,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { mediaFiles.type.equals(MediaType.audio.name).not() & messages.openedAt.isNull() & messages.isWidgetMedia.equals(false) & + _visibleInChat & messages.groupId.equals(groupId) & messages.mediaId.isNotNull() & messages.senderId.isNotNull() & @@ -111,6 +119,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ])..where( messages.openedAt.isNull() & messages.isWidgetMedia.equals(false) & + _visibleInChat & messages.mediaId.isNotNull() & messages.type.equals(MessageType.media.name) & mediaFiles.downloadState.equals(DownloadState.ready.name) & @@ -136,6 +145,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ]) ..where( messages.groupId.equals(groupId) & + _visibleInChat & // messages in groups will only be removed in case all members have received it... // so ensuring that this message is not shown in the messages anymore (messages.openedAt.isBiggerThanValue(deletionTime) | @@ -169,7 +179,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { FROM messages INNER JOIN groups ON groups.group_id = messages.group_id LEFT JOIN media_files ON media_files.media_id = messages.media_id - WHERE ( + WHERE (messages.is_story = 0 OR messages.media_stored = 1) + AND ( messages.opened_at IS NULL OR messages.media_stored = 1 OR messages.opened_at > CAST(strftime('%s', 'now') AS INTEGER) - @@ -207,6 +218,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ]) ..where( messages.groupId.equals(groupId) & + _visibleInChat & (messages.openedAt.isBiggerThanValue(deletionTime) | messages.openedAt.isNull() | messages.mediaStored.equals(true) | @@ -251,6 +263,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ]) ..where( messages.groupId.equals(groupId) & + _visibleInChat & (messages.createdAt.isSmallerThanValue(before) | (messages.createdAt.equals(before) & messages.messageId.isSmallerThanValue( @@ -319,6 +332,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { // without ever clearing the storage its origin holds, so // apps leave only when somebody deletes them. m.type.equals(MessageType.webxdcApp.name).not() & + // Unsaved story rows expire with the story, not the chat. + (m.isStory.equals(false) | m.mediaStored.equals(true)) & ((m.mediaStored.equals(true) & m.isDeletedFromSender.equals(true)) | m.mediaStored.equals(false)) & @@ -645,11 +660,13 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { await transaction(() async { // A one-time app is the chat's durable shared object. Keep its card (and // therefore its instance and update log) while removing the visible chat - // history and every ordinary app. + // history and every ordinary app. Unsaved story rows are not chat + // history; they leave when their story expires. await customStatement( ''' DELETE FROM messages WHERE group_id = ? + AND (is_story = 0 OR media_stored = 1) AND NOT EXISTS ( SELECT 1 FROM webxdc_instances AS instance @@ -671,6 +688,15 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { }); } + /// The rows behind [messageIds], whatever the chat shows of them. A reply + /// can quote a story row that the chat itself never lists. + Stream> watchMessagesByIds(Set messageIds) { + if (messageIds.isEmpty) return Stream.value(const []); + return (select( + messages, + )..where((t) => t.messageId.isIn(messageIds))).watch(); + } + SingleOrNullSelectable getMessageById(String messageId) { return select(messages)..where((t) => t.messageId.equals(messageId)); } diff --git a/lib/src/database/daos/reactions.dao.dart b/lib/src/database/daos/reactions.dao.dart index b8ab9c5a..b7584882 100644 --- a/lib/src/database/daos/reactions.dao.dart +++ b/lib/src/database/daos/reactions.dao.dart @@ -115,7 +115,8 @@ class ReactionsDao extends DatabaseAccessor with _$ReactionsDaoMixin { ..orderBy([(reaction) => OrderingTerm.desc(reaction.createdAt)])) .watch(); } -Stream watchLastReactions(String groupId) { + + Stream watchLastReactions(String groupId) { final query = (select(reactions)).join( [ @@ -126,7 +127,11 @@ Stream watchLastReactions(String groupId) { ), ], ) - ..where(messages.groupId.equals(groupId)) + ..where( + messages.groupId.equals(groupId) & + (messages.isStory.equals(false) | + messages.mediaStored.equals(true)), + ) ..orderBy([OrderingTerm.desc(messages.createdAt)]) ..limit(1); return query.map((row) => row.readTable(reactions)).watchSingleOrNull(); @@ -145,6 +150,7 @@ Stream watchLastReactions(String groupId) { ) AS reaction_rank FROM reactions INNER JOIN messages ON messages.message_id = reactions.message_id + WHERE messages.is_story = 0 OR messages.media_stored = 1 ) AS reaction_rows WHERE reaction_rank = 1 ''', diff --git a/lib/src/database/daos/stories.dao.dart b/lib/src/database/daos/stories.dao.dart new file mode 100644 index 00000000..8646df05 --- /dev/null +++ b/lib/src/database/daos/stories.dao.dart @@ -0,0 +1,203 @@ +import 'package:clock/clock.dart'; +import 'package:drift/drift.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; +import 'package:twonly/src/database/tables/groups.table.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/database/tables/messages.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; + +part 'stories.dao.g.dart'; + +/// How long a story item stays visible after it was posted. Rust purges +/// what is older; the UI only needs it to draw the countdown and to stop +/// showing an item between two purges. +const storyLifetime = Duration(hours: 24); + +/// One item of a contact's story, as this device received it. +class StoryItem { + const StoryItem({required this.message, required this.mediaFile}); + + final Message message; + final MediaFile mediaFile; + + int get senderId => message.senderId!; + DateTime get postedAt => message.createdAt; + DateTime get expiresAt => postedAt.add(storyLifetime); + bool get seen => message.openedAt != null; + bool isActiveAt(DateTime now) => expiresAt.isAfter(now); +} + +/// One item of the user's own story. On this device it is one hidden message +/// per recipient, all sharing [mediaFile]. +class OwnStoryItem { + const OwnStoryItem({ + required this.mediaFile, + required this.postedAt, + required this.recipientCount, + required this.viewerCount, + }); + + final MediaFile mediaFile; + final DateTime postedAt; + final int recipientCount; + final int viewerCount; + + DateTime get expiresAt => postedAt.add(storyLifetime); + bool isActiveAt(DateTime now) => expiresAt.isAfter(now); +} + +/// A recipient of one of the user's story items, and what they did with it. +class StoryViewer { + const StoryViewer({ + required this.contact, + required this.message, + this.openedAt, + }); + + final Contact contact; + + /// The hidden story row for this recipient. + final Message message; + final DateTime? openedAt; + + bool get saved => message.mediaStored; +} + +@DriftAccessor( + tables: [Messages, MediaFiles, Contacts, GroupMembers, MessageActions], +) +class StoriesDao extends DatabaseAccessor with _$StoriesDaoMixin { + // ignore: matching_super_parameters + StoriesDao(super.db); + + JoinedSelectStatement _receivedItems() { + final cutoff = clock.now().subtract(storyLifetime); + return select(messages).join([ + innerJoin(mediaFiles, mediaFiles.mediaId.equalsExp(messages.mediaId)), + innerJoin(contacts, contacts.userId.equalsExp(messages.senderId)), + ]) + ..where( + messages.isStory.equals(true) & + messages.senderId.isNotNull() & + messages.isDeletedFromSender.equals(false) & + messages.createdAt.isBiggerThanValue(cutoff) & + contacts.accepted.equals(true) & + contacts.blocked.equals(false) & + (mediaFiles.downloadState.isNull() | + mediaFiles.downloadState + .equals(DownloadState.reuploadRequested.name) + .not()), + ) + ..orderBy([ + OrderingTerm.asc(messages.createdAt), + OrderingTerm.asc(messages.messageId), + ]); + } + + StoryItem _readItem(TypedResult row) => StoryItem( + message: row.readTable(messages), + mediaFile: row.readTable(mediaFiles), + ); + + /// Every contact's story items that were active when the query last ran, + /// oldest first. Items expire while the stream is quiet, so callers check + /// [StoryItem.isActiveAt] on their own clock. + Stream> watchReceivedStoryItems() { + return _receivedItems().map(_readItem).watch(); + } + + Stream> watchReceivedStoryItemsForGroup(String groupId) { + return (_receivedItems()..where(messages.groupId.equals(groupId))) + .map(_readItem) + .watch(); + } + + Future> getReceivedStoryItems(int contactId) { + return (_receivedItems()..where(messages.senderId.equals(contactId))) + .map(_readItem) + .get(); + } + + /// The user's own story items that are still active, oldest first. + Stream> watchOwnStoryItems() { + return customSelect( + ''' + SELECT media_files.*, + MIN(messages.created_at) AS posted_at, + COUNT(*) AS recipient_count, + SUM(CASE WHEN messages.opened_at IS NULL THEN 0 ELSE 1 END) + AS viewer_count + FROM messages + INNER JOIN media_files ON media_files.media_id = messages.media_id + WHERE messages.is_story = 1 + AND messages.sender_id IS NULL + AND messages.created_at > ? + GROUP BY messages.media_id + ORDER BY posted_at ASC, media_files.media_id ASC + ''', + variables: [Variable(clock.now().subtract(storyLifetime))], + readsFrom: {messages, mediaFiles}, + ).watch().map( + (rows) => rows + .map( + (row) => OwnStoryItem( + mediaFile: mediaFiles.map(row.data), + postedAt: row.read('posted_at'), + recipientCount: row.read('recipient_count'), + viewerCount: row.read('viewer_count'), + ), + ) + .toList(), + ); + } + + /// Everybody one of the user's story items went to. Those who opened it + /// come first, most recent first; the rest follow by name. + Stream> watchStoryViewers(String mediaId) { + final opened = alias(messageActions, 'opened'); + final query = + select(messages).join([ + innerJoin( + groupMembers, + groupMembers.groupId.equalsExp(messages.groupId), + ), + innerJoin( + contacts, + contacts.userId.equalsExp(groupMembers.contactId), + ), + leftOuterJoin( + opened, + opened.messageId.equalsExp(messages.messageId) & + opened.contactId.equalsExp(groupMembers.contactId) & + opened.type.equals(MessageActionType.openedAt.name), + ), + ])..where( + messages.mediaId.equals(mediaId) & + messages.isStory.equals(true) & + messages.senderId.isNull(), + ); + return query.watch().map((rows) { + return rows + .map( + (row) => StoryViewer( + contact: row.readTable(contacts), + message: row.readTable(messages), + openedAt: + row.readTableOrNull(opened)?.actionAt ?? + row.readTable(messages).openedAt, + ), + ) + .toList() + ..sort((a, b) { + final aOpened = a.openedAt; + final bOpened = b.openedAt; + if (aOpened != null && bOpened != null) { + return bOpened.compareTo(aOpened); + } + if (aOpened != null) return -1; + if (bOpened != null) return 1; + return a.contact.username.compareTo(b.contact.username); + }); + }); + } +} diff --git a/lib/src/database/daos/stories.dao.g.dart b/lib/src/database/daos/stories.dao.g.dart new file mode 100644 index 00000000..6d5ce63f --- /dev/null +++ b/lib/src/database/daos/stories.dao.g.dart @@ -0,0 +1,34 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stories.dao.dart'; + +// ignore_for_file: type=lint +mixin _$StoriesDaoMixin on DatabaseAccessor { + $GroupsTable get groups => attachedDatabase.groups; + $ContactsTable get contacts => attachedDatabase.contacts; + $MediaFilesTable get mediaFiles => attachedDatabase.mediaFiles; + $MessagesTable get messages => attachedDatabase.messages; + $GroupMembersTable get groupMembers => attachedDatabase.groupMembers; + $MessageActionsTable get messageActions => attachedDatabase.messageActions; + StoriesDaoManager get managers => StoriesDaoManager(this); +} + +class StoriesDaoManager { + final _$StoriesDaoMixin _db; + StoriesDaoManager(this._db); + $$GroupsTableTableManager get groups => + $$GroupsTableTableManager(_db.attachedDatabase, _db.groups); + $$ContactsTableTableManager get contacts => + $$ContactsTableTableManager(_db.attachedDatabase, _db.contacts); + $$MediaFilesTableTableManager get mediaFiles => + $$MediaFilesTableTableManager(_db.attachedDatabase, _db.mediaFiles); + $$MessagesTableTableManager get messages => + $$MessagesTableTableManager(_db.attachedDatabase, _db.messages); + $$GroupMembersTableTableManager get groupMembers => + $$GroupMembersTableTableManager(_db.attachedDatabase, _db.groupMembers); + $$MessageActionsTableTableManager get messageActions => + $$MessageActionsTableTableManager( + _db.attachedDatabase, + _db.messageActions, + ); +} diff --git a/lib/src/database/tables/contact_groups.table.dart b/lib/src/database/tables/contact_groups.table.dart index 063853a9..42ecee63 100644 --- a/lib/src/database/tables/contact_groups.table.dart +++ b/lib/src/database/tables/contact_groups.table.dart @@ -12,6 +12,7 @@ class ContactGroups extends Table { BoolColumn get showAsShortcut => boolean().withDefault(const Constant(false))(); BoolColumn get showAsLabel => boolean().withDefault(const Constant(true))(); + BoolColumn get shareStories => boolean().withDefault(const Constant(false))(); IntColumn get usageCounter => integer().withDefault(const Constant(0))(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); } diff --git a/lib/src/database/tables/messages.table.dart b/lib/src/database/tables/messages.table.dart index 00a2e675..86a46fab 100644 --- a/lib/src/database/tables/messages.table.dart +++ b/lib/src/database/tables/messages.table.dart @@ -50,6 +50,9 @@ class Messages extends Table { BoolColumn get isWidgetMedia => boolean().withDefault(const Constant(false))(); + /// A story item: hidden from the chat until saved, gone 24h after sending. + BoolColumn get isStory => boolean().withDefault(const Constant(false))(); + DateTimeColumn get openedAt => dateTime().nullable()(); DateTimeColumn get openedByAll => dateTime().nullable()(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); diff --git a/lib/src/database/twonly.db.dart b/lib/src/database/twonly.db.dart index 12784df7..5d835d9d 100644 --- a/lib/src/database/twonly.db.dart +++ b/lib/src/database/twonly.db.dart @@ -9,6 +9,7 @@ import 'package:twonly/src/database/daos/mediafiles.dao.dart'; import 'package:twonly/src/database/daos/messages.dao.dart'; import 'package:twonly/src/database/daos/reactions.dao.dart'; import 'package:twonly/src/database/daos/receipts.dao.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; import 'package:twonly/src/database/daos/user_discovery.dao.dart'; import 'package:twonly/src/database/rust_change_notifier.dart'; import 'package:twonly/src/database/rust_query_executor.dart'; @@ -63,6 +64,7 @@ part 'twonly.db.g.dart'; UserDiscoveryDao, KeyVerificationDao, ContactGroupsDao, + StoriesDao, ], ) class TwonlyDB extends _$TwonlyDB { diff --git a/lib/src/database/twonly.db.g.dart b/lib/src/database/twonly.db.g.dart index 23366fed..99242268 100644 --- a/lib/src/database/twonly.db.g.dart +++ b/lib/src/database/twonly.db.g.dart @@ -5378,6 +5378,21 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { ), defaultValue: const Constant(false), ); + static const VerificationMeta _isStoryMeta = const VerificationMeta( + 'isStory', + ); + @override + late final GeneratedColumn isStory = GeneratedColumn( + 'is_story', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_story" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); static const VerificationMeta _openedAtMeta = const VerificationMeta( 'openedAt', ); @@ -5460,6 +5475,7 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { quotesMessageId, isDeletedFromSender, isWidgetMedia, + isStory, openedAt, openedByAll, createdAt, @@ -5584,6 +5600,12 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { ), ); } + if (data.containsKey('is_story')) { + context.handle( + _isStoryMeta, + isStory.isAcceptableOrUnknown(data['is_story']!, _isStoryMeta), + ); + } if (data.containsKey('opened_at')) { context.handle( _openedAtMeta, @@ -5687,6 +5709,10 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { DriftSqlType.bool, data['${effectivePrefix}is_widget_media'], )!, + isStory: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_story'], + )!, openedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}opened_at'], @@ -5734,6 +5760,9 @@ class Message extends DataClass implements Insertable { final String? quotesMessageId; final bool isDeletedFromSender; final bool isWidgetMedia; + + /// A story item: hidden from the chat until saved, gone 24h after sending. + final bool isStory; final DateTime? openedAt; final DateTime? openedByAll; final DateTime createdAt; @@ -5754,6 +5783,7 @@ class Message extends DataClass implements Insertable { this.quotesMessageId, required this.isDeletedFromSender, required this.isWidgetMedia, + required this.isStory, this.openedAt, this.openedByAll, required this.createdAt, @@ -5791,6 +5821,7 @@ class Message extends DataClass implements Insertable { } map['is_deleted_from_sender'] = Variable(isDeletedFromSender); map['is_widget_media'] = Variable(isWidgetMedia); + map['is_story'] = Variable(isStory); if (!nullToAbsent || openedAt != null) { map['opened_at'] = Variable(openedAt); } @@ -5837,6 +5868,7 @@ class Message extends DataClass implements Insertable { : Value(quotesMessageId), isDeletedFromSender: Value(isDeletedFromSender), isWidgetMedia: Value(isWidgetMedia), + isStory: Value(isStory), openedAt: openedAt == null && nullToAbsent ? const Value.absent() : Value(openedAt), @@ -5879,6 +5911,7 @@ class Message extends DataClass implements Insertable { json['isDeletedFromSender'], ), isWidgetMedia: serializer.fromJson(json['isWidgetMedia']), + isStory: serializer.fromJson(json['isStory']), openedAt: serializer.fromJson(json['openedAt']), openedByAll: serializer.fromJson(json['openedByAll']), createdAt: serializer.fromJson(json['createdAt']), @@ -5906,6 +5939,7 @@ class Message extends DataClass implements Insertable { 'quotesMessageId': serializer.toJson(quotesMessageId), 'isDeletedFromSender': serializer.toJson(isDeletedFromSender), 'isWidgetMedia': serializer.toJson(isWidgetMedia), + 'isStory': serializer.toJson(isStory), 'openedAt': serializer.toJson(openedAt), 'openedByAll': serializer.toJson(openedByAll), 'createdAt': serializer.toJson(createdAt), @@ -5929,6 +5963,7 @@ class Message extends DataClass implements Insertable { Value quotesMessageId = const Value.absent(), bool? isDeletedFromSender, bool? isWidgetMedia, + bool? isStory, Value openedAt = const Value.absent(), Value openedByAll = const Value.absent(), DateTime? createdAt, @@ -5955,6 +5990,7 @@ class Message extends DataClass implements Insertable { : this.quotesMessageId, isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender, isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia, + isStory: isStory ?? this.isStory, openedAt: openedAt.present ? openedAt.value : this.openedAt, openedByAll: openedByAll.present ? openedByAll.value : this.openedByAll, createdAt: createdAt ?? this.createdAt, @@ -5991,6 +6027,7 @@ class Message extends DataClass implements Insertable { isWidgetMedia: data.isWidgetMedia.present ? data.isWidgetMedia.value : this.isWidgetMedia, + isStory: data.isStory.present ? data.isStory.value : this.isStory, openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt, openedByAll: data.openedByAll.present ? data.openedByAll.value @@ -6022,6 +6059,7 @@ class Message extends DataClass implements Insertable { ..write('quotesMessageId: $quotesMessageId, ') ..write('isDeletedFromSender: $isDeletedFromSender, ') ..write('isWidgetMedia: $isWidgetMedia, ') + ..write('isStory: $isStory, ') ..write('openedAt: $openedAt, ') ..write('openedByAll: $openedByAll, ') ..write('createdAt: $createdAt, ') @@ -6047,6 +6085,7 @@ class Message extends DataClass implements Insertable { quotesMessageId, isDeletedFromSender, isWidgetMedia, + isStory, openedAt, openedByAll, createdAt, @@ -6074,6 +6113,7 @@ class Message extends DataClass implements Insertable { other.quotesMessageId == this.quotesMessageId && other.isDeletedFromSender == this.isDeletedFromSender && other.isWidgetMedia == this.isWidgetMedia && + other.isStory == this.isStory && other.openedAt == this.openedAt && other.openedByAll == this.openedByAll && other.createdAt == this.createdAt && @@ -6096,6 +6136,7 @@ class MessagesCompanion extends UpdateCompanion { final Value quotesMessageId; final Value isDeletedFromSender; final Value isWidgetMedia; + final Value isStory; final Value openedAt; final Value openedByAll; final Value createdAt; @@ -6117,6 +6158,7 @@ class MessagesCompanion extends UpdateCompanion { this.quotesMessageId = const Value.absent(), this.isDeletedFromSender = const Value.absent(), this.isWidgetMedia = const Value.absent(), + this.isStory = const Value.absent(), this.openedAt = const Value.absent(), this.openedByAll = const Value.absent(), this.createdAt = const Value.absent(), @@ -6139,6 +6181,7 @@ class MessagesCompanion extends UpdateCompanion { this.quotesMessageId = const Value.absent(), this.isDeletedFromSender = const Value.absent(), this.isWidgetMedia = const Value.absent(), + this.isStory = const Value.absent(), this.openedAt = const Value.absent(), this.openedByAll = const Value.absent(), this.createdAt = const Value.absent(), @@ -6163,6 +6206,7 @@ class MessagesCompanion extends UpdateCompanion { Expression? quotesMessageId, Expression? isDeletedFromSender, Expression? isWidgetMedia, + Expression? isStory, Expression? openedAt, Expression? openedByAll, Expression? createdAt, @@ -6187,6 +6231,7 @@ class MessagesCompanion extends UpdateCompanion { if (isDeletedFromSender != null) 'is_deleted_from_sender': isDeletedFromSender, if (isWidgetMedia != null) 'is_widget_media': isWidgetMedia, + if (isStory != null) 'is_story': isStory, if (openedAt != null) 'opened_at': openedAt, if (openedByAll != null) 'opened_by_all': openedByAll, if (createdAt != null) 'created_at': createdAt, @@ -6211,6 +6256,7 @@ class MessagesCompanion extends UpdateCompanion { Value? quotesMessageId, Value? isDeletedFromSender, Value? isWidgetMedia, + Value? isStory, Value? openedAt, Value? openedByAll, Value? createdAt, @@ -6234,6 +6280,7 @@ class MessagesCompanion extends UpdateCompanion { quotesMessageId: quotesMessageId ?? this.quotesMessageId, isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender, isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia, + isStory: isStory ?? this.isStory, openedAt: openedAt ?? this.openedAt, openedByAll: openedByAll ?? this.openedByAll, createdAt: createdAt ?? this.createdAt, @@ -6288,6 +6335,9 @@ class MessagesCompanion extends UpdateCompanion { if (isWidgetMedia.present) { map['is_widget_media'] = Variable(isWidgetMedia.value); } + if (isStory.present) { + map['is_story'] = Variable(isStory.value); + } if (openedAt.present) { map['opened_at'] = Variable(openedAt.value); } @@ -6328,6 +6378,7 @@ class MessagesCompanion extends UpdateCompanion { ..write('quotesMessageId: $quotesMessageId, ') ..write('isDeletedFromSender: $isDeletedFromSender, ') ..write('isWidgetMedia: $isWidgetMedia, ') + ..write('isStory: $isStory, ') ..write('openedAt: $openedAt, ') ..write('openedByAll: $openedByAll, ') ..write('createdAt: $createdAt, ') @@ -12081,6 +12132,21 @@ class $ContactGroupsTable extends ContactGroups ), defaultValue: const Constant(true), ); + static const VerificationMeta _shareStoriesMeta = const VerificationMeta( + 'shareStories', + ); + @override + late final GeneratedColumn shareStories = GeneratedColumn( + 'share_stories', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("share_stories" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); static const VerificationMeta _usageCounterMeta = const VerificationMeta( 'usageCounter', ); @@ -12114,6 +12180,7 @@ class $ContactGroupsTable extends ContactGroups backgroundColor, showAsShortcut, showAsLabel, + shareStories, usageCounter, createdAt, ]; @@ -12183,6 +12250,15 @@ class $ContactGroupsTable extends ContactGroups ), ); } + if (data.containsKey('share_stories')) { + context.handle( + _shareStoriesMeta, + shareStories.isAcceptableOrUnknown( + data['share_stories']!, + _shareStoriesMeta, + ), + ); + } if (data.containsKey('usage_counter')) { context.handle( _usageCounterMeta, @@ -12235,6 +12311,10 @@ class $ContactGroupsTable extends ContactGroups DriftSqlType.bool, data['${effectivePrefix}show_as_label'], )!, + shareStories: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}share_stories'], + )!, usageCounter: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}usage_counter'], @@ -12260,6 +12340,7 @@ class ContactGroup extends DataClass implements Insertable { final int backgroundColor; final bool showAsShortcut; final bool showAsLabel; + final bool shareStories; final int usageCounter; final DateTime createdAt; const ContactGroup({ @@ -12270,6 +12351,7 @@ class ContactGroup extends DataClass implements Insertable { required this.backgroundColor, required this.showAsShortcut, required this.showAsLabel, + required this.shareStories, required this.usageCounter, required this.createdAt, }); @@ -12285,6 +12367,7 @@ class ContactGroup extends DataClass implements Insertable { map['background_color'] = Variable(backgroundColor); map['show_as_shortcut'] = Variable(showAsShortcut); map['show_as_label'] = Variable(showAsLabel); + map['share_stories'] = Variable(shareStories); map['usage_counter'] = Variable(usageCounter); map['created_at'] = Variable(createdAt); return map; @@ -12301,6 +12384,7 @@ class ContactGroup extends DataClass implements Insertable { backgroundColor: Value(backgroundColor), showAsShortcut: Value(showAsShortcut), showAsLabel: Value(showAsLabel), + shareStories: Value(shareStories), usageCounter: Value(usageCounter), createdAt: Value(createdAt), ); @@ -12319,6 +12403,7 @@ class ContactGroup extends DataClass implements Insertable { backgroundColor: serializer.fromJson(json['backgroundColor']), showAsShortcut: serializer.fromJson(json['showAsShortcut']), showAsLabel: serializer.fromJson(json['showAsLabel']), + shareStories: serializer.fromJson(json['shareStories']), usageCounter: serializer.fromJson(json['usageCounter']), createdAt: serializer.fromJson(json['createdAt']), ); @@ -12334,6 +12419,7 @@ class ContactGroup extends DataClass implements Insertable { 'backgroundColor': serializer.toJson(backgroundColor), 'showAsShortcut': serializer.toJson(showAsShortcut), 'showAsLabel': serializer.toJson(showAsLabel), + 'shareStories': serializer.toJson(shareStories), 'usageCounter': serializer.toJson(usageCounter), 'createdAt': serializer.toJson(createdAt), }; @@ -12347,6 +12433,7 @@ class ContactGroup extends DataClass implements Insertable { int? backgroundColor, bool? showAsShortcut, bool? showAsLabel, + bool? shareStories, int? usageCounter, DateTime? createdAt, }) => ContactGroup( @@ -12357,6 +12444,7 @@ class ContactGroup extends DataClass implements Insertable { backgroundColor: backgroundColor ?? this.backgroundColor, showAsShortcut: showAsShortcut ?? this.showAsShortcut, showAsLabel: showAsLabel ?? this.showAsLabel, + shareStories: shareStories ?? this.shareStories, usageCounter: usageCounter ?? this.usageCounter, createdAt: createdAt ?? this.createdAt, ); @@ -12375,6 +12463,9 @@ class ContactGroup extends DataClass implements Insertable { showAsLabel: data.showAsLabel.present ? data.showAsLabel.value : this.showAsLabel, + shareStories: data.shareStories.present + ? data.shareStories.value + : this.shareStories, usageCounter: data.usageCounter.present ? data.usageCounter.value : this.usageCounter, @@ -12392,6 +12483,7 @@ class ContactGroup extends DataClass implements Insertable { ..write('backgroundColor: $backgroundColor, ') ..write('showAsShortcut: $showAsShortcut, ') ..write('showAsLabel: $showAsLabel, ') + ..write('shareStories: $shareStories, ') ..write('usageCounter: $usageCounter, ') ..write('createdAt: $createdAt') ..write(')')) @@ -12407,6 +12499,7 @@ class ContactGroup extends DataClass implements Insertable { backgroundColor, showAsShortcut, showAsLabel, + shareStories, usageCounter, createdAt, ); @@ -12421,6 +12514,7 @@ class ContactGroup extends DataClass implements Insertable { other.backgroundColor == this.backgroundColor && other.showAsShortcut == this.showAsShortcut && other.showAsLabel == this.showAsLabel && + other.shareStories == this.shareStories && other.usageCounter == this.usageCounter && other.createdAt == this.createdAt); } @@ -12433,6 +12527,7 @@ class ContactGroupsCompanion extends UpdateCompanion { final Value backgroundColor; final Value showAsShortcut; final Value showAsLabel; + final Value shareStories; final Value usageCounter; final Value createdAt; const ContactGroupsCompanion({ @@ -12443,6 +12538,7 @@ class ContactGroupsCompanion extends UpdateCompanion { this.backgroundColor = const Value.absent(), this.showAsShortcut = const Value.absent(), this.showAsLabel = const Value.absent(), + this.shareStories = const Value.absent(), this.usageCounter = const Value.absent(), this.createdAt = const Value.absent(), }); @@ -12454,6 +12550,7 @@ class ContactGroupsCompanion extends UpdateCompanion { required int backgroundColor, this.showAsShortcut = const Value.absent(), this.showAsLabel = const Value.absent(), + this.shareStories = const Value.absent(), this.usageCounter = const Value.absent(), this.createdAt = const Value.absent(), }) : name = Value(name), @@ -12467,6 +12564,7 @@ class ContactGroupsCompanion extends UpdateCompanion { Expression? backgroundColor, Expression? showAsShortcut, Expression? showAsLabel, + Expression? shareStories, Expression? usageCounter, Expression? createdAt, }) { @@ -12478,6 +12576,7 @@ class ContactGroupsCompanion extends UpdateCompanion { if (backgroundColor != null) 'background_color': backgroundColor, if (showAsShortcut != null) 'show_as_shortcut': showAsShortcut, if (showAsLabel != null) 'show_as_label': showAsLabel, + if (shareStories != null) 'share_stories': shareStories, if (usageCounter != null) 'usage_counter': usageCounter, if (createdAt != null) 'created_at': createdAt, }); @@ -12491,6 +12590,7 @@ class ContactGroupsCompanion extends UpdateCompanion { Value? backgroundColor, Value? showAsShortcut, Value? showAsLabel, + Value? shareStories, Value? usageCounter, Value? createdAt, }) { @@ -12502,6 +12602,7 @@ class ContactGroupsCompanion extends UpdateCompanion { backgroundColor: backgroundColor ?? this.backgroundColor, showAsShortcut: showAsShortcut ?? this.showAsShortcut, showAsLabel: showAsLabel ?? this.showAsLabel, + shareStories: shareStories ?? this.shareStories, usageCounter: usageCounter ?? this.usageCounter, createdAt: createdAt ?? this.createdAt, ); @@ -12531,6 +12632,9 @@ class ContactGroupsCompanion extends UpdateCompanion { if (showAsLabel.present) { map['show_as_label'] = Variable(showAsLabel.value); } + if (shareStories.present) { + map['share_stories'] = Variable(shareStories.value); + } if (usageCounter.present) { map['usage_counter'] = Variable(usageCounter.value); } @@ -12550,6 +12654,7 @@ class ContactGroupsCompanion extends UpdateCompanion { ..write('backgroundColor: $backgroundColor, ') ..write('showAsShortcut: $showAsShortcut, ') ..write('showAsLabel: $showAsLabel, ') + ..write('shareStories: $shareStories, ') ..write('usageCounter: $usageCounter, ') ..write('createdAt: $createdAt') ..write(')')) @@ -14928,6 +15033,7 @@ abstract class _$TwonlyDB extends GeneratedDatabase { late final ContactGroupsDao contactGroupsDao = ContactGroupsDao( this as TwonlyDB, ); + late final StoriesDao storiesDao = StoriesDao(this as TwonlyDB); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -16713,7 +16819,7 @@ class $$ContactsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$ContactsTable, Contact>(table), $$ContactsTableReferences(db, table, e), ), ) @@ -18000,8 +18106,10 @@ class $$GroupsTableTableManager ), withReferenceMapper: (p0) => p0 .map( - (e) => - (e.readTable(table), $$GroupsTableReferences(db, table, e)), + (e) => ( + e.readTable<$GroupsTable, Group>(table), + $$GroupsTableReferences(db, table, e), + ), ) .toList(), prefetchHooksCallback: @@ -18996,7 +19104,7 @@ class $$MediaFilesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$MediaFilesTable, MediaFile>(table), $$MediaFilesTableReferences(db, table, e), ), ) @@ -19066,6 +19174,7 @@ typedef $$MessagesTableCreateCompanionBuilder = Value quotesMessageId, Value isDeletedFromSender, Value isWidgetMedia, + Value isStory, Value openedAt, Value openedByAll, Value createdAt, @@ -19089,6 +19198,7 @@ typedef $$MessagesTableUpdateCompanionBuilder = Value quotesMessageId, Value isDeletedFromSender, Value isWidgetMedia, + Value isStory, Value openedAt, Value openedByAll, Value createdAt, @@ -19314,6 +19424,11 @@ class $$MessagesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get isStory => $composableBuilder( + column: $table.isStory, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get openedAt => $composableBuilder( column: $table.openedAt, builder: (column) => ColumnFilters(column), @@ -19598,6 +19713,11 @@ class $$MessagesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get isStory => $composableBuilder( + column: $table.isStory, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get openedAt => $composableBuilder( column: $table.openedAt, builder: (column) => ColumnOrderings(column), @@ -19751,6 +19871,9 @@ class $$MessagesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get isStory => + $composableBuilder(column: $table.isStory, builder: (column) => column); + GeneratedColumn get openedAt => $composableBuilder(column: $table.openedAt, builder: (column) => column); @@ -20020,6 +20143,7 @@ class $$MessagesTableTableManager Value quotesMessageId = const Value.absent(), Value isDeletedFromSender = const Value.absent(), Value isWidgetMedia = const Value.absent(), + Value isStory = const Value.absent(), Value openedAt = const Value.absent(), Value openedByAll = const Value.absent(), Value createdAt = const Value.absent(), @@ -20041,6 +20165,7 @@ class $$MessagesTableTableManager quotesMessageId: quotesMessageId, isDeletedFromSender: isDeletedFromSender, isWidgetMedia: isWidgetMedia, + isStory: isStory, openedAt: openedAt, openedByAll: openedByAll, createdAt: createdAt, @@ -20064,6 +20189,7 @@ class $$MessagesTableTableManager Value quotesMessageId = const Value.absent(), Value isDeletedFromSender = const Value.absent(), Value isWidgetMedia = const Value.absent(), + Value isStory = const Value.absent(), Value openedAt = const Value.absent(), Value openedByAll = const Value.absent(), Value createdAt = const Value.absent(), @@ -20085,6 +20211,7 @@ class $$MessagesTableTableManager quotesMessageId: quotesMessageId, isDeletedFromSender: isDeletedFromSender, isWidgetMedia: isWidgetMedia, + isStory: isStory, openedAt: openedAt, openedByAll: openedByAll, createdAt: createdAt, @@ -20096,7 +20223,7 @@ class $$MessagesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$MessagesTable, Message>(table), $$MessagesTableReferences(db, table, e), ), ) @@ -20643,7 +20770,7 @@ class $$MessageHistoriesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$MessageHistoriesTable, MessageHistory>(table), $$MessageHistoriesTableReferences(db, table, e), ), ) @@ -21031,7 +21158,7 @@ class $$ReactionsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$ReactionsTable, Reaction>(table), $$ReactionsTableReferences(db, table, e), ), ) @@ -21503,7 +21630,7 @@ class $$GroupMembersTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$GroupMembersTable, GroupMember>(table), $$GroupMembersTableReferences(db, table, e), ), ) @@ -22053,7 +22180,7 @@ class $$ReceiptsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$ReceiptsTable, Receipt>(table), $$ReceiptsTableReferences(db, table, e), ), ) @@ -22251,7 +22378,16 @@ class $$ReceivedReceiptsTableTableManager rowid: rowid, ), withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .map( + (e) => ( + e.readTable<$ReceivedReceiptsTable, ReceivedReceipt>(table), + BaseReferences< + _$TwonlyDB, + $ReceivedReceiptsTable, + ReceivedReceipt + >(db, table, e), + ), + ) .toList(), prefetchHooksCallback: null, ), @@ -22587,7 +22723,7 @@ class $$MessageActionsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$MessageActionsTable, MessageAction>(table), $$MessageActionsTableReferences(db, table, e), ), ) @@ -23090,7 +23226,7 @@ class $$GroupHistoriesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$GroupHistoriesTable, GroupHistory>(table), $$GroupHistoriesTableReferences(db, table, e), ), ) @@ -23499,7 +23635,7 @@ class $$KeyVerificationsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$KeyVerificationsTable, KeyVerification>(table), $$KeyVerificationsTableReferences(db, table, e), ), ) @@ -23721,7 +23857,18 @@ class $$VerificationTokensTableTableManager createdAt: createdAt, ), withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .map( + (e) => ( + e.readTable<$VerificationTokensTable, VerificationToken>( + table, + ), + BaseReferences< + _$TwonlyDB, + $VerificationTokensTable, + VerificationToken + >(db, table, e), + ), + ) .toList(), prefetchHooksCallback: null, ), @@ -24076,7 +24223,10 @@ class $$UserDiscoveryAnnouncedUsersTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable< + $UserDiscoveryAnnouncedUsersTable, + UserDiscoveryAnnouncedUser + >(table), $$UserDiscoveryAnnouncedUsersTableReferences(db, table, e), ), ) @@ -24463,7 +24613,10 @@ class $$UserDiscoveryUserRelationsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable< + $UserDiscoveryUserRelationsTable, + UserDiscoveryUserRelation + >(table), $$UserDiscoveryUserRelationsTableReferences(db, table, e), ), ) @@ -24851,7 +25004,10 @@ class $$UserDiscoveryOtherPromotionsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable< + $UserDiscoveryOtherPromotionsTable, + UserDiscoveryOtherPromotion + >(table), $$UserDiscoveryOtherPromotionsTableReferences(db, table, e), ), ) @@ -25154,7 +25310,10 @@ class $$UserDiscoveryOwnPromotionsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable< + $UserDiscoveryOwnPromotionsTable, + UserDiscoveryOwnPromotion + >(table), $$UserDiscoveryOwnPromotionsTableReferences(db, table, e), ), ) @@ -25446,7 +25605,9 @@ class $$UserDiscoverySharesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$UserDiscoverySharesTable, UserDiscoveryShare>( + table, + ), $$UserDiscoverySharesTableReferences(db, table, e), ), ) @@ -25521,6 +25682,7 @@ typedef $$ContactGroupsTableCreateCompanionBuilder = required int backgroundColor, Value showAsShortcut, Value showAsLabel, + Value shareStories, Value usageCounter, Value createdAt, }); @@ -25533,6 +25695,7 @@ typedef $$ContactGroupsTableUpdateCompanionBuilder = Value backgroundColor, Value showAsShortcut, Value showAsLabel, + Value shareStories, Value usageCounter, Value createdAt, }); @@ -25613,6 +25776,11 @@ class $$ContactGroupsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get shareStories => $composableBuilder( + column: $table.shareStories, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get usageCounter => $composableBuilder( column: $table.usageCounter, builder: (column) => ColumnFilters(column), @@ -25693,6 +25861,11 @@ class $$ContactGroupsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get shareStories => $composableBuilder( + column: $table.shareStories, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get usageCounter => $composableBuilder( column: $table.usageCounter, builder: (column) => ColumnOrderings(column), @@ -25740,6 +25913,11 @@ class $$ContactGroupsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get shareStories => $composableBuilder( + column: $table.shareStories, + builder: (column) => column, + ); + GeneratedColumn get usageCounter => $composableBuilder( column: $table.usageCounter, builder: (column) => column, @@ -25810,6 +25988,7 @@ class $$ContactGroupsTableTableManager Value backgroundColor = const Value.absent(), Value showAsShortcut = const Value.absent(), Value showAsLabel = const Value.absent(), + Value shareStories = const Value.absent(), Value usageCounter = const Value.absent(), Value createdAt = const Value.absent(), }) => ContactGroupsCompanion( @@ -25820,6 +25999,7 @@ class $$ContactGroupsTableTableManager backgroundColor: backgroundColor, showAsShortcut: showAsShortcut, showAsLabel: showAsLabel, + shareStories: shareStories, usageCounter: usageCounter, createdAt: createdAt, ), @@ -25832,6 +26012,7 @@ class $$ContactGroupsTableTableManager required int backgroundColor, Value showAsShortcut = const Value.absent(), Value showAsLabel = const Value.absent(), + Value shareStories = const Value.absent(), Value usageCounter = const Value.absent(), Value createdAt = const Value.absent(), }) => ContactGroupsCompanion.insert( @@ -25842,13 +26023,14 @@ class $$ContactGroupsTableTableManager backgroundColor: backgroundColor, showAsShortcut: showAsShortcut, showAsLabel: showAsLabel, + shareStories: shareStories, usageCounter: usageCounter, createdAt: createdAt, ), withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$ContactGroupsTable, ContactGroup>(table), $$ContactGroupsTableReferences(db, table, e), ), ) @@ -26304,7 +26486,9 @@ class $$ContactGroupMembersTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$ContactGroupMembersTable, ContactGroupMember>( + table, + ), $$ContactGroupMembersTableReferences(db, table, e), ), ) @@ -26755,7 +26939,16 @@ class $$WebxdcAppsTableTableManager rowid: rowid, ), withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .map( + (e) => ( + e.readTable<$WebxdcAppsTable, WebxdcApp>(table), + BaseReferences<_$TwonlyDB, $WebxdcAppsTable, WebxdcApp>( + db, + table, + e, + ), + ), + ) .toList(), prefetchHooksCallback: null, ), @@ -27207,7 +27400,7 @@ class $$WebxdcInstancesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$WebxdcInstancesTable, WebxdcInstance>(table), $$WebxdcInstancesTableReferences(db, table, e), ), ) @@ -27510,7 +27703,16 @@ class $$WebxdcUpdatesTableTableManager rowid: rowid, ), withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .map( + (e) => ( + e.readTable<$WebxdcUpdatesTable, WebxdcUpdate>(table), + BaseReferences<_$TwonlyDB, $WebxdcUpdatesTable, WebxdcUpdate>( + db, + table, + e, + ), + ), + ) .toList(), prefetchHooksCallback: null, ), diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 4801fa41..b727c44a 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -4857,6 +4857,144 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'This link opens outside twonly, in your browser:'** String get webxdcExternalLinkBody; + + /// No description provided for @notificationStory. + /// + /// In en, this message translates to: + /// **'posted a story.'** + String get notificationStory; + + /// No description provided for @settingsNotifyStories. + /// + /// In en, this message translates to: + /// **'Story notifications'** + String get settingsNotifyStories; + + /// No description provided for @settingsNotifyStoriesSubtitle. + /// + /// In en, this message translates to: + /// **'Be notified of the first story of the day from contacts you\'ve written with in the last two weeks.'** + String get settingsNotifyStoriesSubtitle; + + /// No description provided for @contactGroupShareStories. + /// + /// In en, this message translates to: + /// **'Share stories'** + String get contactGroupShareStories; + + /// No description provided for @contactGroupShareStoriesSubtitle. + /// + /// In en, this message translates to: + /// **'Offer this group as an audience when you post a story.'** + String get contactGroupShareStoriesSubtitle; + + /// No description provided for @contactGroupStoriesFeature. + /// + /// In en, this message translates to: + /// **'Stories'** + String get contactGroupStoriesFeature; + + /// No description provided for @story. + /// + /// In en, this message translates to: + /// **'Story'** + String get story; + + /// No description provided for @storyExpired. + /// + /// In en, this message translates to: + /// **'Story expired'** + String get storyExpired; + + /// No description provided for @storyMine. + /// + /// In en, this message translates to: + /// **'My story'** + String get storyMine; + + /// No description provided for @storyDeleteTitle. + /// + /// In en, this message translates to: + /// **'Delete story?'** + String get storyDeleteTitle; + + /// No description provided for @storyDeleteBody. + /// + /// In en, this message translates to: + /// **'This removes it from your story.'** + String get storyDeleteBody; + + /// No description provided for @storyDelete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get storyDelete; + + /// No description provided for @storyDeleteFailed. + /// + /// In en, this message translates to: + /// **'Could not delete it. Try again in a moment.'** + String get storyDeleteFailed; + + /// No description provided for @shareImageStory. + /// + /// In en, this message translates to: + /// **'Story'** + String get shareImageStory; + + /// No description provided for @shareImageStoryAudience. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Reaches 1 contact} other{Reaches {count} contacts}}'** + String shareImageStoryAudience(int count); + + /// No description provided for @shareImageStoryUnavailable. + /// + /// In en, this message translates to: + /// **'twonly-protected media can\'t be posted as a story.'** + String get shareImageStoryUnavailable; + + /// No description provided for @shareImageStoryAll. + /// + /// In en, this message translates to: + /// **'All contacts'** + String get shareImageStoryAll; + + /// No description provided for @shareImageStoryNewGroup. + /// + /// In en, this message translates to: + /// **'New story group'** + String get shareImageStoryNewGroup; + + /// No description provided for @shareImageStoryFailed. + /// + /// In en, this message translates to: + /// **'Could not post the story. One send can reach at most {max} people.'** + String shareImageStoryFailed(int max); + + /// No description provided for @notificationStoryReply. + /// + /// In en, this message translates to: + /// **'has replied to your story.'** + String get notificationStoryReply; + + /// No description provided for @notificationStoryReaction. + /// + /// In en, this message translates to: + /// **'has reacted with {reaction} to your story.'** + String notificationStoryReaction(Object reaction); + + /// No description provided for @notificationStoredStory. + /// + /// In en, this message translates to: + /// **'has saved your story.'** + String get notificationStoredStory; + + /// No description provided for @storyViews. + /// + /// In en, this message translates to: + /// **'{count, plural, =0{No views yet} =1{1 view} other{{count} views}}'** + String storyViews(int count); } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_ar.dart b/lib/src/localization/generated/app_localizations_ar.dart index e00eb670..79157185 100644 --- a/lib/src/localization/generated/app_localizations_ar.dart +++ b/lib/src/localization/generated/app_localizations_ar.dart @@ -2851,4 +2851,97 @@ class AppLocalizationsAr extends AppLocalizations { @override String get webxdcExternalLinkBody => 'يُفتح هذا الرابط خارج twonly، في متصفحك:'; + + @override + String get notificationStory => 'قصة جديدة.'; + + @override + String get settingsNotifyStories => 'إشعارات القصص'; + + @override + String get settingsNotifyStoriesSubtitle => + 'تلقَّ إشعارًا بأول قصة في اليوم من جهات الاتصال التي راسلتها خلال الأسبوعين الماضيين.'; + + @override + String get contactGroupShareStories => 'مشاركة القصص'; + + @override + String get contactGroupShareStoriesSubtitle => + 'اعرض هذه المجموعة كجمهور عند نشر قصة.'; + + @override + String get contactGroupStoriesFeature => 'القصص'; + + @override + String get story => 'قصة'; + + @override + String get storyExpired => 'انتهت صلاحية القصة'; + + @override + String get storyMine => 'قصتي'; + + @override + String get storyDeleteTitle => 'حذف القصة؟'; + + @override + String get storyDeleteBody => 'سيؤدي هذا إلى إزالتها من قصتك.'; + + @override + String get storyDelete => 'حذف'; + + @override + String get storyDeleteFailed => 'تعذّر الحذف. حاول مرة أخرى بعد قليل.'; + + @override + String get shareImageStory => 'قصة'; + + @override + String shareImageStoryAudience(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'تصل إلى $count من جهات الاتصال', + one: 'تصل إلى جهة اتصال واحدة', + ); + return '$_temp0'; + } + + @override + String get shareImageStoryUnavailable => + 'لا يمكن نشر الوسائط المحمية بـ twonly كقصة.'; + + @override + String get shareImageStoryAll => 'كل جهات الاتصال'; + + @override + String get shareImageStoryNewGroup => 'مجموعة قصص جديدة'; + + @override + String shareImageStoryFailed(int max) { + return 'تعذّر نشر القصة. يمكن أن يصل الإرسال الواحد إلى $max شخص كحد أقصى.'; + } + + @override + String get notificationStoryReply => 'رد على قصتك.'; + + @override + String notificationStoryReaction(Object reaction) { + return 'تفاعل $reaction مع قصتك.'; + } + + @override + String get notificationStoredStory => 'تم حفظ قصتك.'; + + @override + String storyViews(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count مشاهدات', + one: 'مشاهدة واحدة', + zero: 'لا مشاهدات بعد', + ); + return '$_temp0'; + } } diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 65ec9b15..7ea93672 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -2851,4 +2851,98 @@ class AppLocalizationsDe extends AppLocalizations { @override String get webxdcExternalLinkBody => 'Dieser Link öffnet sich außerhalb von twonly in deinem Browser:'; + + @override + String get notificationStory => 'hat eine Story gepostet.'; + + @override + String get settingsNotifyStories => 'Story-Benachrichtigungen'; + + @override + String get settingsNotifyStoriesSubtitle => + 'Werde über die erste Story des Tages von Kontakten benachrichtigt, mit denen du in den letzten zwei Wochen geschrieben hast.'; + + @override + String get contactGroupShareStories => 'Stories teilen'; + + @override + String get contactGroupShareStoriesSubtitle => + 'Biete diese Gruppe als Publikum an, wenn du eine Story postest.'; + + @override + String get contactGroupStoriesFeature => 'Stories'; + + @override + String get story => 'Story'; + + @override + String get storyExpired => 'Story abgelaufen'; + + @override + String get storyMine => 'Meine Story'; + + @override + String get storyDeleteTitle => 'Story löschen?'; + + @override + String get storyDeleteBody => 'Dadurch wird sie aus deiner Story entfernt.'; + + @override + String get storyDelete => 'Löschen'; + + @override + String get storyDeleteFailed => + 'Konnte nicht gelöscht werden. Versuche es gleich noch einmal.'; + + @override + String get shareImageStory => 'Story'; + + @override + String shareImageStoryAudience(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Erreicht $count Kontakte', + one: 'Erreicht 1 Kontakt', + ); + return '$_temp0'; + } + + @override + String get shareImageStoryUnavailable => + 'Mit twonly geschützte Medien können nicht als Story gepostet werden.'; + + @override + String get shareImageStoryAll => 'Alle Kontakte'; + + @override + String get shareImageStoryNewGroup => 'Neue Story-Gruppe'; + + @override + String shareImageStoryFailed(int max) { + return 'Die Story konnte nicht gepostet werden. Ein Versand erreicht höchstens $max Personen.'; + } + + @override + String get notificationStoryReply => 'hat auf deine Story geantwortet.'; + + @override + String notificationStoryReaction(Object reaction) { + return 'hat mit $reaction auf deine Story reagiert.'; + } + + @override + String get notificationStoredStory => 'hat deine Story gespeichert.'; + + @override + String storyViews(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Aufrufe', + one: '1 Aufruf', + zero: 'Noch keine Aufrufe', + ); + return '$_temp0'; + } } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 08d56b89..a675552e 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -2823,4 +2823,97 @@ class AppLocalizationsEn extends AppLocalizations { @override String get webxdcExternalLinkBody => 'This link opens outside twonly, in your browser:'; + + @override + String get notificationStory => 'posted a story.'; + + @override + String get settingsNotifyStories => 'Story notifications'; + + @override + String get settingsNotifyStoriesSubtitle => + 'Be notified of the first story of the day from contacts you\'ve written with in the last two weeks.'; + + @override + String get contactGroupShareStories => 'Share stories'; + + @override + String get contactGroupShareStoriesSubtitle => + 'Offer this group as an audience when you post a story.'; + + @override + String get contactGroupStoriesFeature => 'Stories'; + + @override + String get story => 'Story'; + + @override + String get storyExpired => 'Story expired'; + + @override + String get storyMine => 'My story'; + + @override + String get storyDeleteTitle => 'Delete story?'; + + @override + String get storyDeleteBody => 'This removes it from your story.'; + + @override + String get storyDelete => 'Delete'; + + @override + String get storyDeleteFailed => 'Could not delete it. Try again in a moment.'; + + @override + String get shareImageStory => 'Story'; + + @override + String shareImageStoryAudience(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Reaches $count contacts', + one: 'Reaches 1 contact', + ); + return '$_temp0'; + } + + @override + String get shareImageStoryUnavailable => + 'twonly-protected media can\'t be posted as a story.'; + + @override + String get shareImageStoryAll => 'All contacts'; + + @override + String get shareImageStoryNewGroup => 'New story group'; + + @override + String shareImageStoryFailed(int max) { + return 'Could not post the story. One send can reach at most $max people.'; + } + + @override + String get notificationStoryReply => 'has replied to your story.'; + + @override + String notificationStoryReaction(Object reaction) { + return 'has reacted with $reaction to your story.'; + } + + @override + String get notificationStoredStory => 'has saved your story.'; + + @override + String storyViews(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count views', + one: '1 view', + zero: 'No views yet', + ); + return '$_temp0'; + } } diff --git a/lib/src/localization/translations/ar.arb b/lib/src/localization/translations/ar.arb index e8b2278a..e3044a26 100644 --- a/lib/src/localization/translations/ar.arb +++ b/lib/src/localization/translations/ar.arb @@ -792,5 +792,49 @@ "webxdcUnavailable": "تعذّر تحميل هذا التطبيق.", "webxdcDeleteConfirm": "سيتم حذف كل ما حفظه هذا التطبيق على هذا الجهاز. يحتفظ الأعضاء الآخرون بنسختهم الخاصة.", "webxdcExternalLinkTitle": "مغادرة twonly؟", - "webxdcExternalLinkBody": "يُفتح هذا الرابط خارج twonly، في متصفحك:" + "webxdcExternalLinkBody": "يُفتح هذا الرابط خارج twonly، في متصفحك:", + "notificationStory": "قصة جديدة.", + "settingsNotifyStories": "إشعارات القصص", + "settingsNotifyStoriesSubtitle": "تلقَّ إشعارًا بأول قصة في اليوم من جهات الاتصال التي راسلتها خلال الأسبوعين الماضيين.", + "contactGroupShareStories": "مشاركة القصص", + "contactGroupShareStoriesSubtitle": "اعرض هذه المجموعة كجمهور عند نشر قصة.", + "contactGroupStoriesFeature": "القصص", + "story": "قصة", + "storyExpired": "انتهت صلاحية القصة", + "storyMine": "قصتي", + "storyDeleteTitle": "حذف القصة؟", + "storyDeleteBody": "سيؤدي هذا إلى إزالتها من قصتك.", + "storyDelete": "حذف", + "storyDeleteFailed": "تعذّر الحذف. حاول مرة أخرى بعد قليل.", + "shareImageStory": "قصة", + "shareImageStoryAudience": "{count, plural, =1{تصل إلى جهة اتصال واحدة} other{تصل إلى {count} من جهات الاتصال}}", + "@shareImageStoryAudience": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shareImageStoryUnavailable": "لا يمكن نشر الوسائط المحمية بـ twonly كقصة.", + "shareImageStoryAll": "كل جهات الاتصال", + "shareImageStoryNewGroup": "مجموعة قصص جديدة", + "shareImageStoryFailed": "تعذّر نشر القصة. يمكن أن يصل الإرسال الواحد إلى {max} شخص كحد أقصى.", + "@shareImageStoryFailed": { + "placeholders": { + "max": { + "type": "int" + } + } + }, + "notificationStoryReply": "رد على قصتك.", + "notificationStoryReaction": "تفاعل {reaction} مع قصتك.", + "notificationStoredStory": "تم حفظ قصتك.", + "storyViews": "{count, plural, =0{لا مشاهدات بعد} =1{مشاهدة واحدة} other{{count} مشاهدات}}", + "@storyViews": { + "placeholders": { + "count": { + "type": "int" + } + } + } } diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index e1481fe6..6f155f15 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -1105,5 +1105,49 @@ "webxdcUnavailable": "Diese App konnte nicht geladen werden.", "webxdcDeleteConfirm": "Alles, was diese App auf diesem Gerät gespeichert hat, wird gelöscht. Andere Mitglieder behalten ihre eigene Kopie.", "webxdcExternalLinkTitle": "twonly verlassen?", - "webxdcExternalLinkBody": "Dieser Link öffnet sich außerhalb von twonly in deinem Browser:" + "webxdcExternalLinkBody": "Dieser Link öffnet sich außerhalb von twonly in deinem Browser:", + "notificationStory": "hat eine Story gepostet.", + "settingsNotifyStories": "Story-Benachrichtigungen", + "settingsNotifyStoriesSubtitle": "Werde über die erste Story des Tages von Kontakten benachrichtigt, mit denen du in den letzten zwei Wochen geschrieben hast.", + "contactGroupShareStories": "Stories teilen", + "contactGroupShareStoriesSubtitle": "Biete diese Gruppe als Publikum an, wenn du eine Story postest.", + "contactGroupStoriesFeature": "Stories", + "story": "Story", + "storyExpired": "Story abgelaufen", + "storyMine": "Meine Story", + "storyDeleteTitle": "Story löschen?", + "storyDeleteBody": "Dadurch wird sie aus deiner Story entfernt.", + "storyDelete": "Löschen", + "storyDeleteFailed": "Konnte nicht gelöscht werden. Versuche es gleich noch einmal.", + "shareImageStory": "Story", + "shareImageStoryAudience": "{count, plural, =1{Erreicht 1 Kontakt} other{Erreicht {count} Kontakte}}", + "@shareImageStoryAudience": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shareImageStoryUnavailable": "Mit twonly geschützte Medien können nicht als Story gepostet werden.", + "shareImageStoryAll": "Alle Kontakte", + "shareImageStoryNewGroup": "Neue Story-Gruppe", + "shareImageStoryFailed": "Die Story konnte nicht gepostet werden. Ein Versand erreicht höchstens {max} Personen.", + "@shareImageStoryFailed": { + "placeholders": { + "max": { + "type": "int" + } + } + }, + "notificationStoryReply": "hat auf deine Story geantwortet.", + "notificationStoryReaction": "hat mit {reaction} auf deine Story reagiert.", + "notificationStoredStory": "hat deine Story gespeichert.", + "storyViews": "{count, plural, =0{Noch keine Aufrufe} =1{1 Aufruf} other{{count} Aufrufe}}", + "@storyViews": { + "placeholders": { + "count": { + "type": "int" + } + } + } } diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index a4918175..037b892b 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -1115,5 +1115,49 @@ "webxdcUnavailable": "This app could not be loaded.", "webxdcDeleteConfirm": "Everything this app saved on this device is deleted. Other members keep their own copy.", "webxdcExternalLinkTitle": "Leave twonly?", - "webxdcExternalLinkBody": "This link opens outside twonly, in your browser:" + "webxdcExternalLinkBody": "This link opens outside twonly, in your browser:", + "notificationStory": "posted a story.", + "settingsNotifyStories": "Story notifications", + "settingsNotifyStoriesSubtitle": "Be notified of the first story of the day from contacts you've written with in the last two weeks.", + "contactGroupShareStories": "Share stories", + "contactGroupShareStoriesSubtitle": "Offer this group as an audience when you post a story.", + "contactGroupStoriesFeature": "Stories", + "story": "Story", + "storyExpired": "Story expired", + "storyMine": "My story", + "storyDeleteTitle": "Delete story?", + "storyDeleteBody": "This removes it from your story.", + "storyDelete": "Delete", + "storyDeleteFailed": "Could not delete it. Try again in a moment.", + "shareImageStory": "Story", + "shareImageStoryAudience": "{count, plural, =1{Reaches 1 contact} other{Reaches {count} contacts}}", + "@shareImageStoryAudience": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shareImageStoryUnavailable": "twonly-protected media can't be posted as a story.", + "shareImageStoryAll": "All contacts", + "shareImageStoryNewGroup": "New story group", + "shareImageStoryFailed": "Could not post the story. One send can reach at most {max} people.", + "@shareImageStoryFailed": { + "placeholders": { + "max": { + "type": "int" + } + } + }, + "notificationStoryReply": "has replied to your story.", + "notificationStoryReaction": "has reacted with {reaction} to your story.", + "notificationStoredStory": "has saved your story.", + "storyViews": "{count, plural, =0{No views yet} =1{1 view} other{{count} views}}", + "@storyViews": { + "placeholders": { + "count": { + "type": "int" + } + } + } } diff --git a/lib/src/model/protobuf/client/generated/messages.pb.dart b/lib/src/model/protobuf/client/generated/messages.pb.dart index 56305f9e..4636be61 100644 --- a/lib/src/model/protobuf/client/generated/messages.pb.dart +++ b/lib/src/model/protobuf/client/generated/messages.pb.dart @@ -373,12 +373,15 @@ class EncryptedContent_TextMessage extends $pb.GeneratedMessage { $core.String? text, $fixnum.Int64? timestamp, $core.String? quoteMessageId, + $core.List<$core.int>? additionalMessageData, }) { final result = create(); if (senderMessageId != null) result.senderMessageId = senderMessageId; if (text != null) result.text = text; if (timestamp != null) result.timestamp = timestamp; if (quoteMessageId != null) result.quoteMessageId = quoteMessageId; + if (additionalMessageData != null) + result.additionalMessageData = additionalMessageData; return result; } @@ -398,6 +401,8 @@ class EncryptedContent_TextMessage extends $pb.GeneratedMessage { ..aOS(2, _omitFieldNames ? '' : 'text') ..aInt64(3, _omitFieldNames ? '' : 'timestamp') ..aOS(4, _omitFieldNames ? '' : 'quoteMessageId') + ..a<$core.List<$core.int>>( + 5, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -457,6 +462,18 @@ class EncryptedContent_TextMessage extends $pb.GeneratedMessage { $core.bool hasQuoteMessageId() => $_has(3); @$pb.TagNumber(4) void clearQuoteMessageId() => $_clearField(4); + + /// Carries where the text came from when it was not typed by the sender -- + /// today, the webxdc app that produced it. + @$pb.TagNumber(5) + $core.List<$core.int> get additionalMessageData => $_getN(4); + @$pb.TagNumber(5) + set additionalMessageData($core.List<$core.int> value) => + $_setBytes(4, value); + @$pb.TagNumber(5) + $core.bool hasAdditionalMessageData() => $_has(4); + @$pb.TagNumber(5) + void clearAdditionalMessageData() => $_clearField(5); } class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage { @@ -465,6 +482,7 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage { $fixnum.Int64? timestamp, $core.String? type, $core.List<$core.int>? additionalMessageData, + $core.bool? hidden, }) { final result = create(); if (senderMessageId != null) result.senderMessageId = senderMessageId; @@ -472,6 +490,7 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage { if (type != null) result.type = type; if (additionalMessageData != null) result.additionalMessageData = additionalMessageData; + if (hidden != null) result.hidden = hidden; return result; } @@ -493,6 +512,7 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage { ..aOS(3, _omitFieldNames ? '' : 'type') ..a<$core.List<$core.int>>( 4, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY) + ..aOB(5, _omitFieldNames ? '' : 'hidden') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -554,6 +574,22 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage { $core.bool hasAdditionalMessageData() => $_has(3); @$pb.TagNumber(4) void clearAdditionalMessageData() => $_clearField(4); + + /// State a feature exchanges rather than something a person sent. A hidden + /// message leaves no row in `messages`, so it never appears in a chat, and + /// it neither raises a notification nor wakes the receiver. + /// + /// The one exception is a webxdc update carrying an `info`: the state stays + /// hidden, but the announcement in it is written to be read and becomes a + /// chat row of its own on arrival, which is worth waking for. + @$pb.TagNumber(5) + $core.bool get hidden => $_getBF(4); + @$pb.TagNumber(5) + set hidden($core.bool value) => $_setBool(4, value); + @$pb.TagNumber(5) + $core.bool hasHidden() => $_has(4); + @$pb.TagNumber(5) + void clearHidden() => $_clearField(5); } class EncryptedContent_Reaction extends $pb.GeneratedMessage { @@ -747,6 +783,7 @@ class EncryptedContent_Media extends $pb.GeneratedMessage { $core.List<$core.int>? encryptionMac, $core.List<$core.int>? encryptionNonce, $core.List<$core.int>? additionalMessageData, + $core.bool? widgetOnly, }) { final result = create(); if (senderMessageId != null) result.senderMessageId = senderMessageId; @@ -763,6 +800,7 @@ class EncryptedContent_Media extends $pb.GeneratedMessage { if (encryptionNonce != null) result.encryptionNonce = encryptionNonce; if (additionalMessageData != null) result.additionalMessageData = additionalMessageData; + if (widgetOnly != null) result.widgetOnly = widgetOnly; return result; } @@ -795,6 +833,7 @@ class EncryptedContent_Media extends $pb.GeneratedMessage { 10, _omitFieldNames ? '' : 'encryptionNonce', $pb.PbFieldType.OY) ..a<$core.List<$core.int>>( 11, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY) + ..aOB(12, _omitFieldNames ? '' : 'widgetOnly') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -916,6 +955,90 @@ class EncryptedContent_Media extends $pb.GeneratedMessage { $core.bool hasAdditionalMessageData() => $_has(10); @$pb.TagNumber(11) void clearAdditionalMessageData() => $_clearField(11); + + @$pb.TagNumber(12) + $core.bool get widgetOnly => $_getBF(11); + @$pb.TagNumber(12) + set widgetOnly($core.bool value) => $_setBool(11, value); + @$pb.TagNumber(12) + $core.bool hasWidgetOnly() => $_has(11); + @$pb.TagNumber(12) + void clearWidgetOnly() => $_clearField(12); +} + +/// A story item. It carries no group ID: the receiver files it under its 1:1 +/// chat with the sender, keeps it for 24 hours after `media.timestamp`, and +/// never shows it in that chat unless it is saved. +class EncryptedContent_Story extends $pb.GeneratedMessage { + factory EncryptedContent_Story({ + EncryptedContent_Media? media, + $core.bool? notify, + }) { + final result = create(); + if (media != null) result.media = media; + if (notify != null) result.notify = notify; + return result; + } + + EncryptedContent_Story._(); + + factory EncryptedContent_Story.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory EncryptedContent_Story.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'EncryptedContent.Story', + createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'media', + subBuilder: EncryptedContent_Media.create) + ..aOB(2, _omitFieldNames ? '' : 'notify') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EncryptedContent_Story clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EncryptedContent_Story copyWith( + void Function(EncryptedContent_Story) updates) => + super.copyWith((message) => updates(message as EncryptedContent_Story)) + as EncryptedContent_Story; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EncryptedContent_Story create() => EncryptedContent_Story._(); + @$core.override + EncryptedContent_Story createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static EncryptedContent_Story getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static EncryptedContent_Story? _defaultInstance; + + @$pb.TagNumber(1) + EncryptedContent_Media get media => $_getN(0); + @$pb.TagNumber(1) + set media(EncryptedContent_Media value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasMedia() => $_has(0); + @$pb.TagNumber(1) + void clearMedia() => $_clearField(1); + @$pb.TagNumber(1) + EncryptedContent_Media ensureMedia() => $_ensure(0); + + /// Whether the sender woke the receiver for this item, which it does for + /// the first story in 24 hours only. The receiver announces the story only + /// when this is set, so a push and its notification never disagree. + @$pb.TagNumber(2) + $core.bool get notify => $_getBF(1); + @$pb.TagNumber(2) + set notify($core.bool value) => $_setBool(1, value); + @$pb.TagNumber(2) + $core.bool hasNotify() => $_has(1); + @$pb.TagNumber(2) + void clearNotify() => $_clearField(2); } class EncryptedContent_MediaUpdate extends $pb.GeneratedMessage { @@ -1649,6 +1772,8 @@ class EncryptedContent extends $pb.GeneratedMessage { EncryptedContent_PasswordLessRecovery? passwordlessRecovery, EncryptedContent_PasswordLessRecoveryHeartbeat? passwordlessRecoveryHeartbeat, + $core.bool? widgetSharingAllowed, + EncryptedContent_Story? story, }) { final result = create(); if (groupId != null) result.groupId = groupId; @@ -1686,6 +1811,9 @@ class EncryptedContent extends $pb.GeneratedMessage { result.passwordlessRecovery = passwordlessRecovery; if (passwordlessRecoveryHeartbeat != null) result.passwordlessRecoveryHeartbeat = passwordlessRecoveryHeartbeat; + if (widgetSharingAllowed != null) + result.widgetSharingAllowed = widgetSharingAllowed; + if (story != null) result.story = story; return result; } @@ -1762,6 +1890,9 @@ class EncryptedContent extends $pb.GeneratedMessage { ..aOM( 27, _omitFieldNames ? '' : 'passwordlessRecoveryHeartbeat', subBuilder: EncryptedContent_PasswordLessRecoveryHeartbeat.create) + ..aOB(29, _omitFieldNames ? '' : 'widgetSharingAllowed') + ..aOM(30, _omitFieldNames ? '' : 'story', + subBuilder: EncryptedContent_Story.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -2071,6 +2202,26 @@ class EncryptedContent extends $pb.GeneratedMessage { @$pb.TagNumber(27) EncryptedContent_PasswordLessRecoveryHeartbeat ensurePasswordlessRecoveryHeartbeat() => $_ensure(24); + + @$pb.TagNumber(29) + $core.bool get widgetSharingAllowed => $_getBF(25); + @$pb.TagNumber(29) + set widgetSharingAllowed($core.bool value) => $_setBool(25, value); + @$pb.TagNumber(29) + $core.bool hasWidgetSharingAllowed() => $_has(25); + @$pb.TagNumber(29) + void clearWidgetSharingAllowed() => $_clearField(29); + + @$pb.TagNumber(30) + EncryptedContent_Story get story => $_getN(26); + @$pb.TagNumber(30) + set story(EncryptedContent_Story value) => $_setField(30, value); + @$pb.TagNumber(30) + $core.bool hasStory() => $_has(26); + @$pb.TagNumber(30) + void clearStory() => $_clearField(30); + @$pb.TagNumber(30) + EncryptedContent_Story ensureStory() => $_ensure(26); } const $core.bool _omitFieldNames = diff --git a/lib/src/model/protobuf/client/generated/messages.pbjson.dart b/lib/src/model/protobuf/client/generated/messages.pbjson.dart index 41adb98a..9c9fa61f 100644 --- a/lib/src/model/protobuf/client/generated/messages.pbjson.dart +++ b/lib/src/model/protobuf/client/generated/messages.pbjson.dart @@ -64,13 +64,22 @@ const EncryptedContent$json = { '10': 'askForFriendPromotions', '17': true }, + { + '1': 'widget_sharing_allowed', + '3': 29, + '4': 1, + '5': 8, + '9': 5, + '10': 'widgetSharingAllowed', + '17': true + }, { '1': 'message_update', '3': 5, '4': 1, '5': 11, '6': '.EncryptedContent.MessageUpdate', - '9': 5, + '9': 6, '10': 'messageUpdate', '17': true }, @@ -80,7 +89,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.Media', - '9': 6, + '9': 7, '10': 'media', '17': true }, @@ -90,7 +99,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.MediaUpdate', - '9': 7, + '9': 8, '10': 'mediaUpdate', '17': true }, @@ -100,7 +109,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.ContactUpdate', - '9': 8, + '9': 9, '10': 'contactUpdate', '17': true }, @@ -110,7 +119,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.ContactRequest', - '9': 9, + '9': 10, '10': 'contactRequest', '17': true }, @@ -120,7 +129,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.FlameSync', - '9': 10, + '9': 11, '10': 'flameSync', '17': true }, @@ -130,7 +139,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.Reaction', - '9': 11, + '9': 12, '10': 'reaction', '17': true }, @@ -140,7 +149,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.TextMessage', - '9': 12, + '9': 13, '10': 'textMessage', '17': true }, @@ -150,7 +159,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.GroupCreate', - '9': 13, + '9': 14, '10': 'groupCreate', '17': true }, @@ -160,7 +169,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.GroupJoin', - '9': 14, + '9': 15, '10': 'groupJoin', '17': true }, @@ -170,7 +179,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.GroupUpdate', - '9': 15, + '9': 16, '10': 'groupUpdate', '17': true }, @@ -180,7 +189,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.ResendGroupPublicKey', - '9': 16, + '9': 17, '10': 'resendGroupPublicKey', '17': true }, @@ -190,7 +199,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.ErrorMessages', - '9': 17, + '9': 18, '10': 'errorMessages', '17': true }, @@ -200,7 +209,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.AdditionalDataMessage', - '9': 18, + '9': 19, '10': 'additionalDataMessage', '17': true }, @@ -210,7 +219,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.TypingIndicator', - '9': 19, + '9': 20, '10': 'typingIndicator', '17': true }, @@ -220,7 +229,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.UserDiscoveryRequest', - '9': 20, + '9': 21, '10': 'userDiscoveryRequest', '17': true }, @@ -230,7 +239,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.UserDiscoveryUpdate', - '9': 21, + '9': 22, '10': 'userDiscoveryUpdate', '17': true }, @@ -240,7 +249,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.KeyVerificationProof', - '9': 22, + '9': 23, '10': 'keyVerificationProof', '17': true }, @@ -250,7 +259,7 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.PasswordLessRecovery', - '9': 23, + '9': 24, '10': 'passwordlessRecovery', '17': true }, @@ -260,10 +269,20 @@ const EncryptedContent$json = { '4': 1, '5': 11, '6': '.EncryptedContent.PasswordLessRecoveryHeartbeat', - '9': 24, + '9': 25, '10': 'passwordlessRecoveryHeartbeat', '17': true }, + { + '1': 'story', + '3': 30, + '4': 1, + '5': 11, + '6': '.EncryptedContent.Story', + '9': 26, + '10': 'story', + '17': true + }, ], '3': [ EncryptedContent_ErrorMessages$json, @@ -276,6 +295,7 @@ const EncryptedContent$json = { EncryptedContent_Reaction$json, EncryptedContent_MessageUpdate$json, EncryptedContent_Media$json, + EncryptedContent_Story$json, EncryptedContent_MediaUpdate$json, EncryptedContent_ContactRequest$json, EncryptedContent_ContactUpdate$json, @@ -293,6 +313,7 @@ const EncryptedContent$json = { {'1': '_sender_profile_counter'}, {'1': '_sender_user_discovery_version'}, {'1': '_ask_for_friend_promotions'}, + {'1': '_widget_sharing_allowed'}, {'1': '_message_update'}, {'1': '_media'}, {'1': '_media_update'}, @@ -313,6 +334,10 @@ const EncryptedContent$json = { {'1': '_key_verification_proof'}, {'1': '_passwordless_recovery'}, {'1': '_passwordless_recovery_heartbeat'}, + {'1': '_story'}, + ], + '9': [ + {'1': 28, '2': 29}, ], }; @@ -440,9 +465,19 @@ const EncryptedContent_TextMessage$json = { '10': 'quoteMessageId', '17': true }, + { + '1': 'additional_message_data', + '3': 5, + '4': 1, + '5': 12, + '9': 1, + '10': 'additionalMessageData', + '17': true + }, ], '8': [ {'1': '_quote_message_id'}, + {'1': '_additional_message_data'}, ], }; @@ -462,6 +497,7 @@ const EncryptedContent_AdditionalDataMessage$json = { '10': 'additionalMessageData', '17': true }, + {'1': 'hidden', '3': 5, '4': 1, '5': 8, '10': 'hidden'}, ], '8': [ {'1': '_additional_message_data'}, @@ -610,6 +646,15 @@ const EncryptedContent_Media$json = { '10': 'additionalMessageData', '17': true }, + { + '1': 'widget_only', + '3': 12, + '4': 1, + '5': 8, + '9': 7, + '10': 'widgetOnly', + '17': true + }, ], '4': [EncryptedContent_Media_Type$json], '8': [ @@ -620,6 +665,7 @@ const EncryptedContent_Media$json = { {'1': '_encryption_mac'}, {'1': '_encryption_nonce'}, {'1': '_additional_message_data'}, + {'1': '_widget_only'}, ], }; @@ -635,6 +681,22 @@ const EncryptedContent_Media_Type$json = { ], }; +@$core.Deprecated('Use encryptedContentDescriptor instead') +const EncryptedContent_Story$json = { + '1': 'Story', + '2': [ + { + '1': 'media', + '3': 1, + '4': 1, + '5': 11, + '6': '.EncryptedContent.Media', + '10': 'media' + }, + {'1': 'notify', '3': 2, '4': 1, '5': 8, '10': 'notify'}, + ], +}; + @$core.Deprecated('Use encryptedContentDescriptor instead') const EncryptedContent_MediaUpdate$json = { '1': 'MediaUpdate', @@ -831,36 +893,38 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode( 'X2NvdW50ZXIYBCABKANIAlIUc2VuZGVyUHJvZmlsZUNvdW50ZXKIAQESRgodc2VuZGVyX3VzZX' 'JfZGlzY292ZXJ5X3ZlcnNpb24YFSABKAxIA1Iac2VuZGVyVXNlckRpc2NvdmVyeVZlcnNpb26I' 'AQESPgoZYXNrX2Zvcl9mcmllbmRfcHJvbW90aW9ucxgZIAEoCEgEUhZhc2tGb3JGcmllbmRQcm' - '9tb3Rpb25ziAEBEksKDm1lc3NhZ2VfdXBkYXRlGAUgASgLMh8uRW5jcnlwdGVkQ29udGVudC5N' - 'ZXNzYWdlVXBkYXRlSAVSDW1lc3NhZ2VVcGRhdGWIAQESMgoFbWVkaWEYBiABKAsyFy5FbmNyeX' - 'B0ZWRDb250ZW50Lk1lZGlhSAZSBW1lZGlhiAEBEkUKDG1lZGlhX3VwZGF0ZRgHIAEoCzIdLkVu' - 'Y3J5cHRlZENvbnRlbnQuTWVkaWFVcGRhdGVIB1ILbWVkaWFVcGRhdGWIAQESSwoOY29udGFjdF' - '91cGRhdGUYCCABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGVICFINY29udGFj' - 'dFVwZGF0ZYgBARJOCg9jb250YWN0X3JlcXVlc3QYCSABKAsyIC5FbmNyeXB0ZWRDb250ZW50Lk' - 'NvbnRhY3RSZXF1ZXN0SAlSDmNvbnRhY3RSZXF1ZXN0iAEBEj8KCmZsYW1lX3N5bmMYCiABKAsy' - 'Gy5FbmNyeXB0ZWRDb250ZW50LkZsYW1lU3luY0gKUglmbGFtZVN5bmOIAQESOwoIcmVhY3Rpb2' - '4YDCABKAsyGi5FbmNyeXB0ZWRDb250ZW50LlJlYWN0aW9uSAtSCHJlYWN0aW9uiAEBEkUKDHRl' - 'eHRfbWVzc2FnZRgNIAEoCzIdLkVuY3J5cHRlZENvbnRlbnQuVGV4dE1lc3NhZ2VIDFILdGV4dE' - '1lc3NhZ2WIAQESRQoMZ3JvdXBfY3JlYXRlGA4gASgLMh0uRW5jcnlwdGVkQ29udGVudC5Hcm91' - 'cENyZWF0ZUgNUgtncm91cENyZWF0ZYgBARI/Cgpncm91cF9qb2luGA8gASgLMhsuRW5jcnlwdG' - 'VkQ29udGVudC5Hcm91cEpvaW5IDlIJZ3JvdXBKb2luiAEBEkUKDGdyb3VwX3VwZGF0ZRgQIAEo' - 'CzIdLkVuY3J5cHRlZENvbnRlbnQuR3JvdXBVcGRhdGVID1ILZ3JvdXBVcGRhdGWIAQESYgoXcm' - 'VzZW5kX2dyb3VwX3B1YmxpY19rZXkYESABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlJlc2VuZEdy' - 'b3VwUHVibGljS2V5SBBSFHJlc2VuZEdyb3VwUHVibGljS2V5iAEBEksKDmVycm9yX21lc3NhZ2' - 'VzGBIgASgLMh8uRW5jcnlwdGVkQ29udGVudC5FcnJvck1lc3NhZ2VzSBFSDWVycm9yTWVzc2Fn' - 'ZXOIAQESZAoXYWRkaXRpb25hbF9kYXRhX21lc3NhZ2UYEyABKAsyJy5FbmNyeXB0ZWRDb250ZW' - '50LkFkZGl0aW9uYWxEYXRhTWVzc2FnZUgSUhVhZGRpdGlvbmFsRGF0YU1lc3NhZ2WIAQESUQoQ' - 'dHlwaW5nX2luZGljYXRvchgUIAEoCzIhLkVuY3J5cHRlZENvbnRlbnQuVHlwaW5nSW5kaWNhdG' - '9ySBNSD3R5cGluZ0luZGljYXRvcogBARJhChZ1c2VyX2Rpc2NvdmVyeV9yZXF1ZXN0GBYgASgL' - 'MiYuRW5jcnlwdGVkQ29udGVudC5Vc2VyRGlzY292ZXJ5UmVxdWVzdEgUUhR1c2VyRGlzY292ZX' - 'J5UmVxdWVzdIgBARJeChV1c2VyX2Rpc2NvdmVyeV91cGRhdGUYFyABKAsyJS5FbmNyeXB0ZWRD' - 'b250ZW50LlVzZXJEaXNjb3ZlcnlVcGRhdGVIFVITdXNlckRpc2NvdmVyeVVwZGF0ZYgBARJhCh' - 'ZrZXlfdmVyaWZpY2F0aW9uX3Byb29mGBggASgLMiYuRW5jcnlwdGVkQ29udGVudC5LZXlWZXJp' - 'ZmljYXRpb25Qcm9vZkgWUhRrZXlWZXJpZmljYXRpb25Qcm9vZogBARJgChVwYXNzd29yZGxlc3' - 'NfcmVjb3ZlcnkYGiABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlBhc3N3b3JkTGVzc1JlY292ZXJ5' - 'SBdSFHBhc3N3b3JkbGVzc1JlY292ZXJ5iAEBEnwKH3Bhc3N3b3JkbGVzc19yZWNvdmVyeV9oZW' - 'FydGJlYXQYGyABKAsyLy5FbmNyeXB0ZWRDb250ZW50LlBhc3N3b3JkTGVzc1JlY292ZXJ5SGVh' - 'cnRiZWF0SBhSHXBhc3N3b3JkbGVzc1JlY292ZXJ5SGVhcnRiZWF0iAEBGpYCCg1FcnJvck1lc3' + '9tb3Rpb25ziAEBEjkKFndpZGdldF9zaGFyaW5nX2FsbG93ZWQYHSABKAhIBVIUd2lkZ2V0U2hh' + 'cmluZ0FsbG93ZWSIAQESSwoObWVzc2FnZV91cGRhdGUYBSABKAsyHy5FbmNyeXB0ZWRDb250ZW' + '50Lk1lc3NhZ2VVcGRhdGVIBlINbWVzc2FnZVVwZGF0ZYgBARIyCgVtZWRpYRgGIAEoCzIXLkVu' + 'Y3J5cHRlZENvbnRlbnQuTWVkaWFIB1IFbWVkaWGIAQESRQoMbWVkaWFfdXBkYXRlGAcgASgLMh' + '0uRW5jcnlwdGVkQ29udGVudC5NZWRpYVVwZGF0ZUgIUgttZWRpYVVwZGF0ZYgBARJLCg5jb250' + 'YWN0X3VwZGF0ZRgIIAEoCzIfLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFVwZGF0ZUgJUg1jb2' + '50YWN0VXBkYXRliAEBEk4KD2NvbnRhY3RfcmVxdWVzdBgJIAEoCzIgLkVuY3J5cHRlZENvbnRl' + 'bnQuQ29udGFjdFJlcXVlc3RIClIOY29udGFjdFJlcXVlc3SIAQESPwoKZmxhbWVfc3luYxgKIA' + 'EoCzIbLkVuY3J5cHRlZENvbnRlbnQuRmxhbWVTeW5jSAtSCWZsYW1lU3luY4gBARI7CghyZWFj' + 'dGlvbhgMIAEoCzIaLkVuY3J5cHRlZENvbnRlbnQuUmVhY3Rpb25IDFIIcmVhY3Rpb26IAQESRQ' + 'oMdGV4dF9tZXNzYWdlGA0gASgLMh0uRW5jcnlwdGVkQ29udGVudC5UZXh0TWVzc2FnZUgNUgt0' + 'ZXh0TWVzc2FnZYgBARJFCgxncm91cF9jcmVhdGUYDiABKAsyHS5FbmNyeXB0ZWRDb250ZW50Lk' + 'dyb3VwQ3JlYXRlSA5SC2dyb3VwQ3JlYXRliAEBEj8KCmdyb3VwX2pvaW4YDyABKAsyGy5FbmNy' + 'eXB0ZWRDb250ZW50Lkdyb3VwSm9pbkgPUglncm91cEpvaW6IAQESRQoMZ3JvdXBfdXBkYXRlGB' + 'AgASgLMh0uRW5jcnlwdGVkQ29udGVudC5Hcm91cFVwZGF0ZUgQUgtncm91cFVwZGF0ZYgBARJi' + 'ChdyZXNlbmRfZ3JvdXBfcHVibGljX2tleRgRIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuUmVzZW' + '5kR3JvdXBQdWJsaWNLZXlIEVIUcmVzZW5kR3JvdXBQdWJsaWNLZXmIAQESSwoOZXJyb3JfbWVz' + 'c2FnZXMYEiABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXNIElINZXJyb3JNZX' + 'NzYWdlc4gBARJkChdhZGRpdGlvbmFsX2RhdGFfbWVzc2FnZRgTIAEoCzInLkVuY3J5cHRlZENv' + 'bnRlbnQuQWRkaXRpb25hbERhdGFNZXNzYWdlSBNSFWFkZGl0aW9uYWxEYXRhTWVzc2FnZYgBAR' + 'JRChB0eXBpbmdfaW5kaWNhdG9yGBQgASgLMiEuRW5jcnlwdGVkQ29udGVudC5UeXBpbmdJbmRp' + 'Y2F0b3JIFFIPdHlwaW5nSW5kaWNhdG9yiAEBEmEKFnVzZXJfZGlzY292ZXJ5X3JlcXVlc3QYFi' + 'ABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlVzZXJEaXNjb3ZlcnlSZXF1ZXN0SBVSFHVzZXJEaXNj' + 'b3ZlcnlSZXF1ZXN0iAEBEl4KFXVzZXJfZGlzY292ZXJ5X3VwZGF0ZRgXIAEoCzIlLkVuY3J5cH' + 'RlZENvbnRlbnQuVXNlckRpc2NvdmVyeVVwZGF0ZUgWUhN1c2VyRGlzY292ZXJ5VXBkYXRliAEB' + 'EmEKFmtleV92ZXJpZmljYXRpb25fcHJvb2YYGCABKAsyJi5FbmNyeXB0ZWRDb250ZW50LktleV' + 'ZlcmlmaWNhdGlvblByb29mSBdSFGtleVZlcmlmaWNhdGlvblByb29miAEBEmAKFXBhc3N3b3Jk' + 'bGVzc19yZWNvdmVyeRgaIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuUGFzc3dvcmRMZXNzUmVjb3' + 'ZlcnlIGFIUcGFzc3dvcmRsZXNzUmVjb3ZlcnmIAQESfAofcGFzc3dvcmRsZXNzX3JlY292ZXJ5' + 'X2hlYXJ0YmVhdBgbIAEoCzIvLkVuY3J5cHRlZENvbnRlbnQuUGFzc3dvcmRMZXNzUmVjb3Zlcn' + 'lIZWFydGJlYXRIGVIdcGFzc3dvcmRsZXNzUmVjb3ZlcnlIZWFydGJlYXSIAQESMgoFc3RvcnkY' + 'HiABKAsyFy5FbmNyeXB0ZWRDb250ZW50LlN0b3J5SBpSBXN0b3J5iAEBGpYCCg1FcnJvck1lc3' 'NhZ2VzEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXMuVHlw' 'ZVIEdHlwZRIsChJyZWxhdGVkX3JlY2VpcHRfaWQYAiABKAlSEHJlbGF0ZWRSZWNlaXB0SWQinA' 'EKBFR5cGUSPAo4RVJST1JfUFJPQ0VTU0lOR19NRVNTQUdFX0NSRUFURURfQUNDT1VOVF9SRVFV' @@ -875,65 +939,70 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode( 'X25hbWUYAyABKAlIAVIMbmV3R3JvdXBOYW1liAEBElcKJm5ld19kZWxldGVfbWVzc2FnZXNfYW' 'Z0ZXJfbWlsbGlzZWNvbmRzGAQgASgDSAJSIm5ld0RlbGV0ZU1lc3NhZ2VzQWZ0ZXJNaWxsaXNl' 'Y29uZHOIAQFCFgoUX2FmZmVjdGVkX2NvbnRhY3RfaWRCEQoPX25ld19ncm91cF9uYW1lQikKJ1' - '9uZXdfZGVsZXRlX21lc3NhZ2VzX2FmdGVyX21pbGxpc2Vjb25kcxqvAQoLVGV4dE1lc3NhZ2US' + '9uZXdfZGVsZXRlX21lc3NhZ2VzX2FmdGVyX21pbGxpc2Vjb25kcxqIAgoLVGV4dE1lc3NhZ2US' 'KgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3NlbmRlck1lc3NhZ2VJZBISCgR0ZXh0GAIgAS' 'gJUgR0ZXh0EhwKCXRpbWVzdGFtcBgDIAEoA1IJdGltZXN0YW1wEi0KEHF1b3RlX21lc3NhZ2Vf' - 'aWQYBCABKAlIAFIOcXVvdGVNZXNzYWdlSWSIAQFCEwoRX3F1b3RlX21lc3NhZ2VfaWQazgEKFU' - 'FkZGl0aW9uYWxEYXRhTWVzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2VuZGVy' - 'TWVzc2FnZUlkEhwKCXRpbWVzdGFtcBgCIAEoA1IJdGltZXN0YW1wEhIKBHR5cGUYAyABKAlSBH' - 'R5cGUSOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYBCABKAxIAFIVYWRkaXRpb25hbE1lc3Nh' - 'Z2VEYXRhiAEBQhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRpkCghSZWFjdGlvbhIqChF0YX' - 'JnZXRfbWVzc2FnZV9pZBgBIAEoCVIPdGFyZ2V0TWVzc2FnZUlkEhQKBWVtb2ppGAIgASgJUgVl' - 'bW9qaRIWCgZyZW1vdmUYAyABKAhSBnJlbW92ZRq+AgoNTWVzc2FnZVVwZGF0ZRI4CgR0eXBlGA' - 'EgASgOMiQuRW5jcnlwdGVkQ29udGVudC5NZXNzYWdlVXBkYXRlLlR5cGVSBHR5cGUSLwoRc2Vu' - 'ZGVyX21lc3NhZ2VfaWQYAiABKAlIAFIPc2VuZGVyTWVzc2FnZUlkiAEBEj0KG211bHRpcGxlX3' - 'RhcmdldF9tZXNzYWdlX2lkcxgDIAMoCVIYbXVsdGlwbGVUYXJnZXRNZXNzYWdlSWRzEhcKBHRl' - 'eHQYBCABKAlIAVIEdGV4dIgBARIcCgl0aW1lc3RhbXAYBSABKANSCXRpbWVzdGFtcCItCgRUeX' - 'BlEgoKBkRFTEVURRAAEg0KCUVESVRfVEVYVBABEgoKBk9QRU5FRBACQhQKEl9zZW5kZXJfbWVz' - 'c2FnZV9pZEIHCgVfdGV4dBqFBgoFTWVkaWESKgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3' - 'NlbmRlck1lc3NhZ2VJZBIwCgR0eXBlGAIgASgOMhwuRW5jcnlwdGVkQ29udGVudC5NZWRpYS5U' - 'eXBlUgR0eXBlEkYKHWRpc3BsYXlfbGltaXRfaW5fbWlsbGlzZWNvbmRzGAMgASgDSABSGmRpc3' - 'BsYXlMaW1pdEluTWlsbGlzZWNvbmRziAEBEjcKF3JlcXVpcmVzX2F1dGhlbnRpY2F0aW9uGAQg' - 'ASgIUhZyZXF1aXJlc0F1dGhlbnRpY2F0aW9uEhwKCXRpbWVzdGFtcBgFIAEoA1IJdGltZXN0YW' - '1wEi0KEHF1b3RlX21lc3NhZ2VfaWQYBiABKAlIAVIOcXVvdGVNZXNzYWdlSWSIAQESKgoOZG93' - 'bmxvYWRfdG9rZW4YByABKAxIAlINZG93bmxvYWRUb2tlbogBARIqCg5lbmNyeXB0aW9uX2tleR' - 'gIIAEoDEgDUg1lbmNyeXB0aW9uS2V5iAEBEioKDmVuY3J5cHRpb25fbWFjGAkgASgMSARSDWVu' - 'Y3J5cHRpb25NYWOIAQESLgoQZW5jcnlwdGlvbl9ub25jZRgKIAEoDEgFUg9lbmNyeXB0aW9uTm' - '9uY2WIAQESOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYCyABKAxIBlIVYWRkaXRpb25hbE1l' - 'c3NhZ2VEYXRhiAEBIj4KBFR5cGUSDAoIUkVVUExPQUQQABIJCgVJTUFHRRABEgkKBVZJREVPEA' - 'ISBwoDR0lGEAMSCQoFQVVESU8QBEIgCh5fZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY29uZHNC' - 'EwoRX3F1b3RlX21lc3NhZ2VfaWRCEQoPX2Rvd25sb2FkX3Rva2VuQhEKD19lbmNyeXB0aW9uX2' - 'tleUIRCg9fZW5jcnlwdGlvbl9tYWNCEwoRX2VuY3J5cHRpb25fbm9uY2VCGgoYX2FkZGl0aW9u' - 'YWxfbWVzc2FnZV9kYXRhGqkBCgtNZWRpYVVwZGF0ZRI2CgR0eXBlGAEgASgOMiIuRW5jcnlwdG' - 'VkQ29udGVudC5NZWRpYVVwZGF0ZS5UeXBlUgR0eXBlEioKEXRhcmdldF9tZXNzYWdlX2lkGAIg' - 'ASgJUg90YXJnZXRNZXNzYWdlSWQiNgoEVHlwZRIMCghSRU9QRU5FRBAAEgoKBlNUT1JFRBABEh' - 'QKEERFQ1JZUFRJT05fRVJST1IQAhp4Cg5Db250YWN0UmVxdWVzdBI5CgR0eXBlGAEgASgOMiUu' - 'RW5jcnlwdGVkQ29udGVudC5Db250YWN0UmVxdWVzdC5UeXBlUgR0eXBlIisKBFR5cGUSCwoHUk' - 'VRVUVTVBAAEgoKBlJFSkVDVBABEgoKBkFDQ0VQVBACGqQCCg1Db250YWN0VXBkYXRlEjgKBHR5' - 'cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGUuVHlwZVIEdHlwZRI3Ch' - 'VhdmF0YXJfc3ZnX2NvbXByZXNzZWQYAiABKAxIAFITYXZhdGFyU3ZnQ29tcHJlc3NlZIgBARIf' - 'Cgh1c2VybmFtZRgDIAEoCUgBUgh1c2VybmFtZYgBARImCgxkaXNwbGF5X25hbWUYBCABKAlIAl' - 'ILZGlzcGxheU5hbWWIAQEiHwoEVHlwZRILCgdSRVFVRVNUEAASCgoGVVBEQVRFEAFCGAoWX2F2' - 'YXRhcl9zdmdfY29tcHJlc3NlZEILCglfdXNlcm5hbWVCDwoNX2Rpc3BsYXlfbmFtZRqvAQoJRm' - 'xhbWVTeW5jEiMKDWZsYW1lX2NvdW50ZXIYASABKANSDGZsYW1lQ291bnRlchI5ChlsYXN0X2Zs' - 'YW1lX2NvdW50ZXJfY2hhbmdlGAIgASgDUhZsYXN0RmxhbWVDb3VudGVyQ2hhbmdlEh8KC2Jlc3' - 'RfZnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiEKDGZvcmNlX3VwZGF0ZRgEIAEoCFILZm9yY2VV' - 'cGRhdGUaTQoPVHlwaW5nSW5kaWNhdG9yEhsKCWlzX3R5cGluZxgBIAEoCFIIaXNUeXBpbmcSHQ' - 'oKY3JlYXRlZF9hdBgCIAEoA1IJY3JlYXRlZEF0Gj8KFFVzZXJEaXNjb3ZlcnlSZXF1ZXN0EicK' - 'D2N1cnJlbnRfdmVyc2lvbhgBIAEoDFIOY3VycmVudFZlcnNpb24aMQoTVXNlckRpc2NvdmVyeV' - 'VwZGF0ZRIaCghtZXNzYWdlcxgBIAMoDFIIbWVzc2FnZXMaPQoUS2V5VmVyaWZpY2F0aW9uUHJv' - 'b2YSJQoOY2FsY3VsYXRlZF9tYWMYASABKAxSDWNhbGN1bGF0ZWRNYWMamwEKFFBhc3N3b3JkTG' - 'Vzc1JlY292ZXJ5EjUKE3JlY292ZXJ5U2VjcmV0U2hhcmUYASABKAxIAFITcmVjb3ZlcnlTZWNy' - 'ZXRTaGFyZYgBARIWCgZkZWxldGUYAiABKAhSBmRlbGV0ZRIcCgl0aHJlc2hvbGQYAyABKANSCX' - 'RocmVzaG9sZEIWChRfcmVjb3ZlcnlTZWNyZXRTaGFyZRozCh1QYXNzd29yZExlc3NSZWNvdmVy' - 'eUhlYXJ0YmVhdBISCgRoYXNoGAEgASgMUgRoYXNoQgsKCV9ncm91cF9pZEIRCg9faXNfZGlyZW' - 'N0X2NoYXRCGQoXX3NlbmRlcl9wcm9maWxlX2NvdW50ZXJCIAoeX3NlbmRlcl91c2VyX2Rpc2Nv' - 'dmVyeV92ZXJzaW9uQhwKGl9hc2tfZm9yX2ZyaWVuZF9wcm9tb3Rpb25zQhEKD19tZXNzYWdlX3' - 'VwZGF0ZUIICgZfbWVkaWFCDwoNX21lZGlhX3VwZGF0ZUIRCg9fY29udGFjdF91cGRhdGVCEgoQ' - 'X2NvbnRhY3RfcmVxdWVzdEINCgtfZmxhbWVfc3luY0ILCglfcmVhY3Rpb25CDwoNX3RleHRfbW' - 'Vzc2FnZUIPCg1fZ3JvdXBfY3JlYXRlQg0KC19ncm91cF9qb2luQg8KDV9ncm91cF91cGRhdGVC' - 'GgoYX3Jlc2VuZF9ncm91cF9wdWJsaWNfa2V5QhEKD19lcnJvcl9tZXNzYWdlc0IaChhfYWRkaX' - 'Rpb25hbF9kYXRhX21lc3NhZ2VCEwoRX3R5cGluZ19pbmRpY2F0b3JCGQoXX3VzZXJfZGlzY292' - 'ZXJ5X3JlcXVlc3RCGAoWX3VzZXJfZGlzY292ZXJ5X3VwZGF0ZUIZChdfa2V5X3ZlcmlmaWNhdG' - 'lvbl9wcm9vZkIYChZfcGFzc3dvcmRsZXNzX3JlY292ZXJ5QiIKIF9wYXNzd29yZGxlc3NfcmVj' - 'b3ZlcnlfaGVhcnRiZWF0'); + 'aWQYBCABKAlIAFIOcXVvdGVNZXNzYWdlSWSIAQESOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdG' + 'EYBSABKAxIAVIVYWRkaXRpb25hbE1lc3NhZ2VEYXRhiAEBQhMKEV9xdW90ZV9tZXNzYWdlX2lk' + 'QhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRrmAQoVQWRkaXRpb25hbERhdGFNZXNzYWdlEi' + 'oKEXNlbmRlcl9tZXNzYWdlX2lkGAEgASgJUg9zZW5kZXJNZXNzYWdlSWQSHAoJdGltZXN0YW1w' + 'GAIgASgDUgl0aW1lc3RhbXASEgoEdHlwZRgDIAEoCVIEdHlwZRI7ChdhZGRpdGlvbmFsX21lc3' + 'NhZ2VfZGF0YRgEIAEoDEgAUhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQESFgoGaGlkZGVuGAUg' + 'ASgIUgZoaWRkZW5CGgoYX2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGmQKCFJlYWN0aW9uEioKEX' + 'RhcmdldF9tZXNzYWdlX2lkGAEgASgJUg90YXJnZXRNZXNzYWdlSWQSFAoFZW1vamkYAiABKAlS' + 'BWVtb2ppEhYKBnJlbW92ZRgDIAEoCFIGcmVtb3ZlGr4CCg1NZXNzYWdlVXBkYXRlEjgKBHR5cG' + 'UYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50Lk1lc3NhZ2VVcGRhdGUuVHlwZVIEdHlwZRIvChFz' + 'ZW5kZXJfbWVzc2FnZV9pZBgCIAEoCUgAUg9zZW5kZXJNZXNzYWdlSWSIAQESPQobbXVsdGlwbG' + 'VfdGFyZ2V0X21lc3NhZ2VfaWRzGAMgAygJUhhtdWx0aXBsZVRhcmdldE1lc3NhZ2VJZHMSFwoE' + 'dGV4dBgEIAEoCUgBUgR0ZXh0iAEBEhwKCXRpbWVzdGFtcBgFIAEoA1IJdGltZXN0YW1wIi0KBF' + 'R5cGUSCgoGREVMRVRFEAASDQoJRURJVF9URVhUEAESCgoGT1BFTkVEEAJCFAoSX3NlbmRlcl9t' + 'ZXNzYWdlX2lkQgcKBV90ZXh0GrsGCgVNZWRpYRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCV' + 'IPc2VuZGVyTWVzc2FnZUlkEjAKBHR5cGUYAiABKA4yHC5FbmNyeXB0ZWRDb250ZW50Lk1lZGlh' + 'LlR5cGVSBHR5cGUSRgodZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY29uZHMYAyABKANIAFIaZG' + 'lzcGxheUxpbWl0SW5NaWxsaXNlY29uZHOIAQESNwoXcmVxdWlyZXNfYXV0aGVudGljYXRpb24Y' + 'BCABKAhSFnJlcXVpcmVzQXV0aGVudGljYXRpb24SHAoJdGltZXN0YW1wGAUgASgDUgl0aW1lc3' + 'RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZBgGIAEoCUgBUg5xdW90ZU1lc3NhZ2VJZIgBARIqCg5k' + 'b3dubG9hZF90b2tlbhgHIAEoDEgCUg1kb3dubG9hZFRva2VuiAEBEioKDmVuY3J5cHRpb25fa2' + 'V5GAggASgMSANSDWVuY3J5cHRpb25LZXmIAQESKgoOZW5jcnlwdGlvbl9tYWMYCSABKAxIBFIN' + 'ZW5jcnlwdGlvbk1hY4gBARIuChBlbmNyeXB0aW9uX25vbmNlGAogASgMSAVSD2VuY3J5cHRpb2' + '5Ob25jZYgBARI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgLIAEoDEgGUhVhZGRpdGlvbmFs' + 'TWVzc2FnZURhdGGIAQESJAoLd2lkZ2V0X29ubHkYDCABKAhIB1IKd2lkZ2V0T25seYgBASI+Cg' + 'RUeXBlEgwKCFJFVVBMT0FEEAASCQoFSU1BR0UQARIJCgVWSURFTxACEgcKA0dJRhADEgkKBUFV' + 'RElPEARCIAoeX2Rpc3BsYXlfbGltaXRfaW5fbWlsbGlzZWNvbmRzQhMKEV9xdW90ZV9tZXNzYW' + 'dlX2lkQhEKD19kb3dubG9hZF90b2tlbkIRCg9fZW5jcnlwdGlvbl9rZXlCEQoPX2VuY3J5cHRp' + 'b25fbWFjQhMKEV9lbmNyeXB0aW9uX25vbmNlQhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YU' + 'IOCgxfd2lkZ2V0X29ubHkaTgoFU3RvcnkSLQoFbWVkaWEYASABKAsyFy5FbmNyeXB0ZWRDb250' + 'ZW50Lk1lZGlhUgVtZWRpYRIWCgZub3RpZnkYAiABKAhSBm5vdGlmeRqpAQoLTWVkaWFVcGRhdG' + 'USNgoEdHlwZRgBIAEoDjIiLkVuY3J5cHRlZENvbnRlbnQuTWVkaWFVcGRhdGUuVHlwZVIEdHlw' + 'ZRIqChF0YXJnZXRfbWVzc2FnZV9pZBgCIAEoCVIPdGFyZ2V0TWVzc2FnZUlkIjYKBFR5cGUSDA' + 'oIUkVPUEVORUQQABIKCgZTVE9SRUQQARIUChBERUNSWVBUSU9OX0VSUk9SEAIaeAoOQ29udGFj' + 'dFJlcXVlc3QSOQoEdHlwZRgBIAEoDjIlLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFJlcXVlc3' + 'QuVHlwZVIEdHlwZSIrCgRUeXBlEgsKB1JFUVVFU1QQABIKCgZSRUpFQ1QQARIKCgZBQ0NFUFQQ' + 'AhqkAgoNQ29udGFjdFVwZGF0ZRI4CgR0eXBlGAEgASgOMiQuRW5jcnlwdGVkQ29udGVudC5Db2' + '50YWN0VXBkYXRlLlR5cGVSBHR5cGUSNwoVYXZhdGFyX3N2Z19jb21wcmVzc2VkGAIgASgMSABS' + 'E2F2YXRhclN2Z0NvbXByZXNzZWSIAQESHwoIdXNlcm5hbWUYAyABKAlIAVIIdXNlcm5hbWWIAQ' + 'ESJgoMZGlzcGxheV9uYW1lGAQgASgJSAJSC2Rpc3BsYXlOYW1liAEBIh8KBFR5cGUSCwoHUkVR' + 'VUVTVBAAEgoKBlVQREFURRABQhgKFl9hdmF0YXJfc3ZnX2NvbXByZXNzZWRCCwoJX3VzZXJuYW' + '1lQg8KDV9kaXNwbGF5X25hbWUarwEKCUZsYW1lU3luYxIjCg1mbGFtZV9jb3VudGVyGAEgASgD' + 'UgxmbGFtZUNvdW50ZXISOQoZbGFzdF9mbGFtZV9jb3VudGVyX2NoYW5nZRgCIAEoA1IWbGFzdE' + 'ZsYW1lQ291bnRlckNoYW5nZRIfCgtiZXN0X2ZyaWVuZBgDIAEoCFIKYmVzdEZyaWVuZBIhCgxm' + 'b3JjZV91cGRhdGUYBCABKAhSC2ZvcmNlVXBkYXRlGk0KD1R5cGluZ0luZGljYXRvchIbCglpc1' + '90eXBpbmcYASABKAhSCGlzVHlwaW5nEh0KCmNyZWF0ZWRfYXQYAiABKANSCWNyZWF0ZWRBdBo/' + 'ChRVc2VyRGlzY292ZXJ5UmVxdWVzdBInCg9jdXJyZW50X3ZlcnNpb24YASABKAxSDmN1cnJlbn' + 'RWZXJzaW9uGjEKE1VzZXJEaXNjb3ZlcnlVcGRhdGUSGgoIbWVzc2FnZXMYASADKAxSCG1lc3Nh' + 'Z2VzGj0KFEtleVZlcmlmaWNhdGlvblByb29mEiUKDmNhbGN1bGF0ZWRfbWFjGAEgASgMUg1jYW' + 'xjdWxhdGVkTWFjGpsBChRQYXNzd29yZExlc3NSZWNvdmVyeRI1ChNyZWNvdmVyeVNlY3JldFNo' + 'YXJlGAEgASgMSABSE3JlY292ZXJ5U2VjcmV0U2hhcmWIAQESFgoGZGVsZXRlGAIgASgIUgZkZW' + 'xldGUSHAoJdGhyZXNob2xkGAMgASgDUgl0aHJlc2hvbGRCFgoUX3JlY292ZXJ5U2VjcmV0U2hh' + 'cmUaMwodUGFzc3dvcmRMZXNzUmVjb3ZlcnlIZWFydGJlYXQSEgoEaGFzaBgBIAEoDFIEaGFzaE' + 'ILCglfZ3JvdXBfaWRCEQoPX2lzX2RpcmVjdF9jaGF0QhkKF19zZW5kZXJfcHJvZmlsZV9jb3Vu' + 'dGVyQiAKHl9zZW5kZXJfdXNlcl9kaXNjb3ZlcnlfdmVyc2lvbkIcChpfYXNrX2Zvcl9mcmllbm' + 'RfcHJvbW90aW9uc0IZChdfd2lkZ2V0X3NoYXJpbmdfYWxsb3dlZEIRCg9fbWVzc2FnZV91cGRh' + 'dGVCCAoGX21lZGlhQg8KDV9tZWRpYV91cGRhdGVCEQoPX2NvbnRhY3RfdXBkYXRlQhIKEF9jb2' + '50YWN0X3JlcXVlc3RCDQoLX2ZsYW1lX3N5bmNCCwoJX3JlYWN0aW9uQg8KDV90ZXh0X21lc3Nh' + 'Z2VCDwoNX2dyb3VwX2NyZWF0ZUINCgtfZ3JvdXBfam9pbkIPCg1fZ3JvdXBfdXBkYXRlQhoKGF' + '9yZXNlbmRfZ3JvdXBfcHVibGljX2tleUIRCg9fZXJyb3JfbWVzc2FnZXNCGgoYX2FkZGl0aW9u' + 'YWxfZGF0YV9tZXNzYWdlQhMKEV90eXBpbmdfaW5kaWNhdG9yQhkKF191c2VyX2Rpc2NvdmVyeV' + '9yZXF1ZXN0QhgKFl91c2VyX2Rpc2NvdmVyeV91cGRhdGVCGQoXX2tleV92ZXJpZmljYXRpb25f' + 'cHJvb2ZCGAoWX3Bhc3N3b3JkbGVzc19yZWNvdmVyeUIiCiBfcGFzc3dvcmRsZXNzX3JlY292ZX' + 'J5X2hlYXJ0YmVhdEIICgZfc3RvcnlKBAgcEB0='); diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index 6e75c3c9..50176376 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -97,6 +97,24 @@ final routerProvider = GoRouter( return MediaViewerView(group); }, ), + GoRoute( + path: 'story/:contactId', + builder: (context, state) { + final contactId = int.parse(state.pathParameters['contactId']!); + return MediaViewerView.story( + StorySource.contact( + contactId, + initialMediaId: state.extra as String?, + ), + ); + }, + ), + GoRoute( + path: 'own_story', + builder: (context, state) => MediaViewerView.story( + StorySource.own(initialMediaId: state.extra as String?), + ), + ), GoRoute( path: 'messages/:groupId', builder: (context, state) { diff --git a/lib/src/services/notifications/native.notifications.dart b/lib/src/services/notifications/native.notifications.dart index 3d959d6b..57e0c8f4 100644 --- a/lib/src/services/notifications/native.notifications.dart +++ b/lib/src/services/notifications/native.notifications.dart @@ -37,11 +37,16 @@ class NativeNotificationService { /// Emits metadata for every native notification tapped while the app runs. static Stream get taps => _taps.stream; - /// Text messages (including quoted replies) and the announcements an app - /// writes into a chat open their conversation. Media and every other - /// notification kind stay on the chat overview. + /// Text messages (including quoted replies, and replies and reactions to + /// the user's story) and the announcements an app writes into a chat open + /// their conversation. Media and every other notification kind stay on the + /// chat overview. static bool opensConversation(String? kind) => - kind == 'text' || kind == 'response' || kind == 'webxdc'; + kind == 'text' || + kind == 'response' || + kind == 'webxdc' || + kind == 'story_reply' || + kind == 'story_reaction'; static void init() { _startBadgeSync(); diff --git a/lib/src/visual/components/recipient_status_row.comp.dart b/lib/src/visual/components/recipient_status_row.comp.dart new file mode 100644 index 00000000..08bac9c5 --- /dev/null +++ b/lib/src/visual/components/recipient_status_row.comp.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; + +/// One recipient of something the user sent, with what last happened to it +/// there ("Opened", "Received", ...) and when. +class RecipientStatusRow extends StatelessWidget { + const RecipientStatusRow({ + required this.contact, + required this.status, + required this.at, + this.badge, + super.key, + }); + + final Contact contact; + final String status; + final DateTime at; + + /// Shown after the name, e.g. that the recipient saved it. + final Widget? badge; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + AvatarIcon(contactId: contact.userId, fontSize: 15), + const SizedBox(width: 6), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + getContactDisplayName(contact), + style: const TextStyle(fontSize: 17), + overflow: TextOverflow.ellipsis, + ), + ), + if (badge != null) ...[const SizedBox(width: 6), badge!], + ], + ), + ), + Column( + children: [ + Text( + friendlyDateTime(context, at), + style: const TextStyle(fontSize: 12), + ), + Text(status), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/visual/components/story_preview.comp.dart b/lib/src/visual/components/story_preview.comp.dart new file mode 100644 index 00000000..edb6ec1f --- /dev/null +++ b/lib/src/visual/components/story_preview.comp.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/utils/misc.dart'; + +/// A story item's picture standing in for an avatar: the image itself, or +/// the frame taken from a video. Until that file is on the device it is a +/// plain tile, so a story still being downloaded is visible as one. +class StoryPreview extends StatelessWidget { + const StoryPreview({ + required this.mediaFile, + required this.width, + required this.height, + this.circle = false, + this.radius = 12, + this.faded = false, + this.badge, + super.key, + }); + + final MediaFile mediaFile; + final double width; + final double height; + + /// Clip to a circle instead of a rounded rectangle. + final bool circle; + final double radius; + + /// Drawn softer, for a story that has been seen completely. + final bool faded; + + /// Drawn small over the bottom-right corner, such as whose story it is. + final Widget? badge; + + @override + Widget build(BuildContext context) { + final isVideo = mediaFile.type == MediaType.video; + final service = MediaFileService(mediaFile); + // A story stays in its temporary file for as long as it lives; a video + // is shown by the frame Rust takes from it. + final source = isVideo ? service.thumbnailPath : service.tempPath; + final placeholder = ColoredBox( + color: context.color.surfaceContainerHighest, + child: Center( + child: FaIcon( + isVideo ? FontAwesomeIcons.play : FontAwesomeIcons.image, + size: width / 3, + color: context.color.onSurface.withAlpha(90), + ), + ), + ); + Widget picture = Image.file( + source, + // A file that appears later, once downloaded, is drawn then. + key: ValueKey( + '${mediaFile.mediaId}/${mediaFile.downloadState}/${mediaFile.hasThumbnail}', + ), + width: width, + height: height, + fit: BoxFit.cover, + cacheWidth: (width * MediaQuery.devicePixelRatioOf(context)).round(), + errorBuilder: (_, _, _) => placeholder, + ); + picture = circle + ? ClipOval(child: picture) + : ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: picture, + ); + if (faded) picture = Opacity(opacity: 0.5, child: picture); + final sized = SizedBox(width: width, height: height, child: picture); + if (badge == null) return sized; + return SizedBox( + width: width, + height: height, + child: Stack( + clipBehavior: Clip.none, + children: [ + sized, + Positioned( + right: -4, + bottom: -4, + child: Container( + padding: const EdgeInsets.all(1.5), + decoration: BoxDecoration( + color: context.color.surface, + borderRadius: BorderRadius.circular(8), + ), + child: badge, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/visual/elements/my_chip.element.dart b/lib/src/visual/elements/my_chip.element.dart new file mode 100644 index 00000000..8d6fd14f --- /dev/null +++ b/lib/src/visual/elements/my_chip.element.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/reactive_tap_feedback.element.dart'; + +/// A borderless pill for a row of choices. A selected chip takes the primary +/// colour, like a primary button, instead of showing a checkmark. +class MyChip extends StatelessWidget { + const MyChip({ + required this.label, + required this.onTap, + this.onLongPress, + this.selected = false, + this.tooltip, + super.key, + }); + + final Widget label; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool selected; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final isDark = isDarkMode(context); + final background = selected + ? context.color.primary + : isDark + ? Colors.grey[800]! + : Colors.grey[200]!; + final foreground = selected + ? Colors.black87 + : isDark + ? Colors.white + : Colors.black87; + Widget chip = AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 14), + alignment: Alignment.center, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(18), + ), + child: IconTheme.merge( + data: IconThemeData(color: foreground, size: 18), + child: DefaultTextStyle.merge( + style: TextStyle( + color: foreground, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + child: label, + ), + ), + ); + if (tooltip != null) chip = Tooltip(message: tooltip, child: chip); + return Semantics( + button: true, + selected: selected, + child: ReactiveTapFeedback( + onTap: onTap, + onLongPress: onLongPress, + child: chip, + ), + ); + } +} diff --git a/lib/src/visual/views/camera/share_image_contact_selection.view.dart b/lib/src/visual/views/camera/share_image_contact_selection.view.dart index c6ba9347..a426409d 100644 --- a/lib/src/visual/views/camera/share_image_contact_selection.view.dart +++ b/lib/src/visual/views/camera/share_image_contact_selection.view.dart @@ -4,25 +4,51 @@ import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/core/bridge/groups.dart' as rust_groups; +import 'package:twonly/core/bridge/stories.dart' as stories; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/contact_request_badge.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart'; -import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; +import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/elements/headline.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/best_friends_selector.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart'; +import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/story_target_selector.comp.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/background.layer.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart'; +bool canSendSharedMedia({ + required bool mediaReady, + required bool sending, + required bool hasChatRecipients, + required bool storyPossible, + required bool hasStoryTarget, + required int? storyAudienceSize, +}) { + return mediaReady && + !sending && + (hasChatRecipients || + (storyPossible && hasStoryTarget && (storyAudienceSize ?? 0) > 0)); +} + +/// Every chat a send reaches: those picked directly and, with a story, the +/// 1:1 chat of everybody the story goes to. Someone picked directly who also +/// gets the story counts once. +int sharedMediaRecipientCount({ + required Set selectedGroupIds, + required Set? storyAudienceChats, +}) => {...selectedGroupIds, ...?storyAudienceChats}.length; + class ShareImageView extends StatefulWidget { const ShareImageView({ required this.selectedGroupIds, @@ -31,6 +57,7 @@ class ShareImageView extends StatefulWidget { required this.mediaFileService, required this.additionalData, required this.sendToWidget, + required this.storyTarget, super.key, }); final HashSet selectedGroupIds; @@ -39,6 +66,7 @@ class ShareImageView extends StatefulWidget { final MediaFileService mediaFileService; final AdditionalMessageData? additionalData; final bool sendToWidget; + final StoryTarget storyTarget; @override State createState() => _ShareImageView(); @@ -57,6 +85,7 @@ class _ShareImageView extends State { late StreamSubscription> allGroupSub; String lastQuery = ''; int _updateGroupsGeneration = 0; + Set? _storyAudienceChats; @override void initState() { @@ -96,6 +125,7 @@ class _ShareImageView extends State { @override void dispose() { unawaited(allGroupSub.cancel()); + searchUserName.dispose(); super.dispose(); } @@ -172,6 +202,58 @@ class _ShareImageView extends State { setState(() {}); } + /// A story replays for everybody until it expires, so it takes neither a + /// twonly-protected send nor anything but an image or video. + bool get _storyPossible { + final media = widget.mediaFileService.mediaFile; + return !media.requiresAuthentication && + (media.type == MediaType.image || media.type == MediaType.video); + } + + int get _recipientCount => sharedMediaRecipientCount( + selectedGroupIds: widget.selectedGroupIds, + storyAudienceChats: _storyPossible && !widget.storyTarget.isEmpty + ? _storyAudienceChats + : null, + ); + + Future _send() async { + setState(() { + sendingImage = true; + }); + final story = _storyPossible ? widget.storyTarget.toAudience() : null; + // in case mediaStoreFutureReady is ready, the image is stored in the originalPath + final send = RustApi.sendMediaToGroups( + mediaId: widget.mediaFileService.mediaFile.mediaId, + groupIds: widget.selectedGroupIds.toList(), + additionalMessageData: widget.additionalData?.writeToBuffer(), + widgetOnly: widget.sendToWidget, + storyAudience: story, + ); + if (story == null) { + unawaitedRustCall(send, 'sendMediaToGroups'); + } else { + // A story can be refused, most likely for reaching more people than one + // send may, and the user has to hear about that before leaving here. + try { + await send; + } catch (e) { + Log.error('sendMediaToGroups with a story failed: $e'); + if (mounted) { + setState(() => sendingImage = false); + showSnackbar( + context, + context.lang.shareImageStoryFailed(stories.maxMediaRecipients()), + ); + } + return; + } + } + if (mounted) { + Navigator.pop(context, true); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -195,22 +277,35 @@ class _ShareImageView extends State { const EmptyChatListComp() else ...[ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: TextField( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: MyInput( + controller: searchUserName, + dense: true, + fontWeight: FontWeight.normal, + hintText: context.lang.shareImageSearchAllContacts, + prefixIcon: const Icon(Icons.search, size: 20), onChanged: _filterUsers, - decoration: getInputDecoration( - context, - context.lang.shareImageSearchAllContacts, - ), ), ), - const SizedBox(height: 10), - if (!widget.sendToWidget) + const SizedBox(height: 12), + if (!widget.sendToWidget) ...[ ContactGroupShortcutRow( selectedGroupIds: widget.selectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds, ), - if (_pinnedContacts.isNotEmpty) const SizedBox(height: 10), + const SizedBox(height: 10), + StoryTargetSelector( + target: widget.storyTarget, + enabled: _storyPossible, + onChanged: () => setState(() {}), + onAudienceChanged: (chats) { + if (!mounted) return; + setState(() => _storyAudienceChats = chats); + }, + ), + // The next section's heading brings its own space. + const SizedBox(height: 4), + ], BestFriendsSelector( groups: _pinnedContacts, selectedGroupIds: widget.selectedGroupIds, @@ -220,7 +315,7 @@ class _ShareImageView extends State { !widget.sendToWidget && !widget.mediaFileService.mediaFile.requiresAuthentication, ), - const SizedBox(height: 10), + if (_pinnedContacts.isNotEmpty) const SizedBox(height: 10), BestFriendsSelector( groups: _bestFriends, selectedGroupIds: widget.selectedGroupIds, @@ -230,7 +325,7 @@ class _ShareImageView extends State { !widget.sendToWidget && !widget.mediaFileService.mediaFile.requiresAuthentication, ), - const SizedBox(height: 10), + if (_bestFriends.isNotEmpty) const SizedBox(height: 10), if (_otherUsers.isNotEmpty) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -319,32 +414,17 @@ class _ShareImageView extends State { MyButton( variant: MyButtonVariant.primaryMiddle, onPressed: - !mediaStoreFutureReady || - widget.selectedGroupIds.isEmpty || - sendingImage - ? null - : () async { - setState(() { - sendingImage = true; - }); - - // in case mediaStoreFutureReady is ready, the image is stored in the originalPath - unawaitedRustCall( - RustApi.sendMediaToGroups( - mediaId: - widget.mediaFileService.mediaFile.mediaId, - groupIds: widget.selectedGroupIds.toList(), - additionalMessageData: widget.additionalData - ?.writeToBuffer(), - widgetOnly: widget.sendToWidget, - ), - 'sendMediaToGroups', - ); - - if (context.mounted) { - Navigator.pop(context, true); - } - }, + canSendSharedMedia( + mediaReady: mediaStoreFutureReady, + sending: sendingImage, + hasChatRecipients: + widget.selectedGroupIds.isNotEmpty, + storyPossible: _storyPossible, + hasStoryTarget: !widget.storyTarget.isEmpty, + storyAudienceSize: _storyAudienceChats?.length, + ) + ? _send + : null, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -366,7 +446,7 @@ class _ShareImageView extends State { ), const SizedBox(width: 8), Text( - '${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})', + '${context.lang.shareImagedEditorSendImage} ($_recipientCount)', ), ], ), diff --git a/lib/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart b/lib/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart index d4867e25..63e211ac 100644 --- a/lib/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart +++ b/lib/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_chip.element.dart'; +import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/first_seen_order.dart'; import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart'; class ContactGroupShortcutRow extends StatefulWidget { @@ -24,6 +26,11 @@ class ContactGroupShortcutRow extends StatefulWidget { class _ContactGroupShortcutRowState extends State { List _contactGroups = []; + final List _order = []; + + /// The chats each shortcut selects, by contact group. + Map> _targets = {}; + int _targetsGeneration = 0; late final StreamSubscription> _subscription; @override @@ -32,7 +39,11 @@ class _ContactGroupShortcutRowState extends State { _subscription = twonlyDB.contactGroupsDao .watchShortcutContactGroups() .listen((contactGroups) { - if (mounted) setState(() => _contactGroups = contactGroups); + if (!mounted) return; + setState( + () => _contactGroups = inFirstSeenOrder(contactGroups, _order), + ); + unawaited(_loadTargets()); }); } @@ -42,17 +53,17 @@ class _ContactGroupShortcutRowState extends State { super.dispose(); } - Future _openSettings([ContactGroup? contactGroup]) async { - await context.navPush( - ContactGroupSettingsView( - contactGroup: contactGroup, - initialShowAsShortcut: contactGroup == null, - ), - ); + Future _loadTargets() async { + final generation = ++_targetsGeneration; + final targets = { + for (final contactGroup in _contactGroups) + contactGroup.id: await _resolveTargets(contactGroup), + }; + if (!mounted || generation != _targetsGeneration) return; + setState(() => _targets = targets); } - Future _apply(ContactGroup contactGroup) async { - await twonlyDB.contactGroupsDao.incrementUsage(contactGroup.id); + Future> _resolveTargets(ContactGroup contactGroup) async { final members = await twonlyDB.contactGroupsDao.getMembers(contactGroup.id); final targetGroupIds = {}; for (final member in members) { @@ -65,6 +76,38 @@ class _ContactGroupShortcutRowState extends State { if (directChat != null) targetGroupIds.add(directChat.groupId); } } + return targetGroupIds; + } + + /// A shortcut is on while every chat it stands for is selected, however + /// they came to be. + bool _isSelected(Set? targets) => + targets != null && + targets.isNotEmpty && + widget.selectedGroupIds.containsAll(targets); + + Future _openSettings([ContactGroup? contactGroup]) async { + await context.navPush( + ContactGroupSettingsView( + contactGroup: contactGroup, + initialShowAsShortcut: contactGroup == null, + ), + ); + // Members may have changed, which the contact groups alone do not show. + if (mounted) await _loadTargets(); + } + + Future _apply(ContactGroup contactGroup) async { + final targetGroupIds = await _resolveTargets(contactGroup); + if (!mounted) return; + // Tapping a shortcut that is on turns it off again. + if (_isSelected(targetGroupIds)) { + for (final groupId in targetGroupIds) { + widget.updateSelectedGroupIds(groupId, false); + } + return; + } + await twonlyDB.contactGroupsDao.incrementUsage(contactGroup.id); for (final groupId in widget.selectedGroupIds.toList()) { widget.updateSelectedGroupIds(groupId, false); } @@ -76,36 +119,31 @@ class _ContactGroupShortcutRowState extends State { @override Widget build(BuildContext context) { return SizedBox( - height: 40, + height: 36, child: ListView( scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 4), children: [ - ActionChip( - padding: EdgeInsets.zero, + MyChip( tooltip: context.lang.createContactGroup, - onPressed: _openSettings, + onTap: _openSettings, label: _contactGroups.isEmpty - ? Text( - context.lang.createContactGroup, - style: const TextStyle(fontSize: 9), - ) - : const Icon(Icons.add_reaction_outlined, size: 20), - shape: const StadiumBorder(), + ? Text(context.lang.createContactGroup) + : const Icon(Icons.add_reaction_outlined), ), - for (final contactGroup in _contactGroups) - GestureDetector( + for (final contactGroup in _contactGroups) ...[ + const SizedBox(width: 8), + MyChip( + tooltip: contactGroup.name, + selected: _isSelected(_targets[contactGroup.id]), + onTap: () => _apply(contactGroup), onLongPress: () => _openSettings(contactGroup), - child: ActionChip( - padding: EdgeInsets.zero, - tooltip: contactGroup.name, - onPressed: () => _apply(contactGroup), - label: Text( - contactGroup.emoji ?? contactGroup.name, - style: const TextStyle(fontSize: 18), - ), - shape: const StadiumBorder(), + label: Text( + contactGroup.emoji ?? contactGroup.name, + style: const TextStyle(fontSize: 18), ), ), + ], ], ), ); diff --git a/lib/src/visual/views/camera/share_image_contact_selection_components/first_seen_order.dart b/lib/src/visual/views/camera/share_image_contact_selection_components/first_seen_order.dart new file mode 100644 index 00000000..ad9b0d2b --- /dev/null +++ b/lib/src/visual/views/camera/share_image_contact_selection_components/first_seen_order.dart @@ -0,0 +1,18 @@ +import 'package:twonly/src/database/twonly.db.dart'; + +/// Keeps a row of contact group chips in the order they first appeared. +/// Groups arrive most used first and choosing one counts as a use, so taking +/// every new order as it comes would move a chip away right after it was +/// tapped. Groups that are gone are dropped and new ones go to the end. +/// [seenIds] carries the order from one call to the next. +List inFirstSeenOrder( + List contactGroups, + List seenIds, +) { + final byId = {for (final group in contactGroups) group.id: group}; + seenIds.removeWhere((id) => !byId.containsKey(id)); + for (final group in contactGroups) { + if (!seenIds.contains(group.id)) seenIds.add(group.id); + } + return [for (final id in seenIds) byId[id]!]; +} diff --git a/lib/src/visual/views/camera/share_image_contact_selection_components/story_target_selector.comp.dart b/lib/src/visual/views/camera/share_image_contact_selection_components/story_target_selector.comp.dart new file mode 100644 index 00000000..a34abce4 --- /dev/null +++ b/lib/src/visual/views/camera/share_image_contact_selection_components/story_target_selector.comp.dart @@ -0,0 +1,219 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart' + show Int64List; +import 'package:twonly/core/bridge/stories.dart' as stories; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/headline.element.dart'; +import 'package:twonly/src/visual/elements/my_chip.element.dart'; +import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/first_seen_order.dart'; +import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart'; + +/// Where a send also posts the media as a story: to every contact, or to the +/// contact groups that share stories. Kept by the editor, so the choice +/// survives a trip back to it. +class StoryTarget { + bool all = false; + final Set contactGroupIds = {}; + + bool get isEmpty => !all && contactGroupIds.isEmpty; + + void clear() { + all = false; + contactGroupIds.clear(); + } + + stories.StoryAudience? toAudience() => isEmpty + ? null + : stories.StoryAudience( + all: all, + contactGroupIds: Int64List.fromList(contactGroupIds.toList()), + ); +} + +/// The story choice at the top of the send screen. "All" and single contact +/// groups exclude each other: everybody already includes every group. +class StoryTargetSelector extends StatefulWidget { + const StoryTargetSelector({ + required this.target, + required this.enabled, + required this.onChanged, + required this.onAudienceChanged, + super.key, + }); + + final StoryTarget target; + + /// Off for media a story cannot carry, such as twonly-protected sends. + final bool enabled; + final VoidCallback onChanged; + + /// The 1:1 chats the chosen story reaches, or null while nothing is + /// chosen or the answer is still coming. + final ValueChanged?> onAudienceChanged; + + @override + State createState() => _StoryTargetSelectorState(); +} + +class _StoryTargetSelectorState extends State { + List _contactGroups = []; + final List _order = []; + late final StreamSubscription> _subscription; + Set? _audienceChats; + int _audienceGeneration = 0; + + @override + void initState() { + super.initState(); + _subscription = twonlyDB.contactGroupsDao.watchStoryContactGroups().listen(( + contactGroups, + ) { + if (!mounted) return; + // A group that stopped sharing stories cannot stay selected. + final ids = contactGroups.map((group) => group.id).toSet(); + final before = widget.target.contactGroupIds.length; + widget.target.contactGroupIds.retainAll(ids); + setState( + () => _contactGroups = inFirstSeenOrder(contactGroups, _order), + ); + if (before != widget.target.contactGroupIds.length) _changed(); + }); + unawaited(_updateAudience()); + } + + @override + void dispose() { + unawaited(_subscription.cancel()); + super.dispose(); + } + + void _changed() { + widget.onChanged(); + if (_audienceChats != null) { + _audienceChats = null; + widget.onAudienceChanged(null); + } + setState(() {}); + unawaited(_updateAudience()); + } + + Future _updateAudience() async { + final generation = ++_audienceGeneration; + final audience = widget.target.toAudience(); + final chats = audience == null + ? null + : (await stories.storyAudienceChats(audience: audience)).toSet(); + if (!mounted || generation != _audienceGeneration) return; + widget.onAudienceChanged(chats); + setState(() => _audienceChats = chats); + } + + void _toggleAll() { + final selected = !widget.target.all; + widget.target + ..clear() + ..all = selected; + _changed(); + } + + void _toggleGroup(ContactGroup contactGroup) { + final ids = (widget.target..all = false).contactGroupIds; + if (!ids.remove(contactGroup.id)) ids.add(contactGroup.id); + _changed(); + } + + Future _createGroup() async { + final id = await context.navPush( + const ContactGroupSettingsView(initialShareStories: true), + ); + if (id is int && mounted) { + widget.target + ..all = false + ..contactGroupIds.add(id); + _changed(); + } + } + + @override + Widget build(BuildContext context) { + final target = widget.target; + // In line with the other headings on the send screen, which come with + // this much inset of their own. + const inset = EdgeInsets.symmetric(horizontal: 4); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 4), + child: Row( + children: [ + HeadLineComp(context.lang.shareImageStory), + const Spacer(), + if (widget.enabled && _audienceChats != null) + Text( + context.lang.shareImageStoryAudience(_audienceChats!.length), + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + if (!widget.enabled) + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Text( + context.lang.shareImageStoryUnavailable, + style: TextStyle( + fontSize: 12, + color: context.color.onSurface.withAlpha(150), + ), + ), + ) + else + SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + padding: inset, + children: [ + MyChip( + label: Text(context.lang.shareImageStoryAll), + selected: target.all, + onTap: _toggleAll, + ), + for (final contactGroup in _contactGroups) ...[ + const SizedBox(width: 8), + MyChip( + label: Text( + [ + if (contactGroup.emoji != null) contactGroup.emoji!, + contactGroup.name, + ].join(' '), + ), + selected: target.contactGroupIds.contains( + contactGroup.id, + ), + onTap: () => _toggleGroup(contactGroup), + onLongPress: () => context.navPush( + ContactGroupSettingsView(contactGroup: contactGroup), + ), + ), + ], + const SizedBox(width: 8), + MyChip( + tooltip: context.lang.shareImageStoryNewGroup, + onTap: _createGroup, + label: _contactGroups.isEmpty + ? Text(context.lang.shareImageStoryNewGroup) + : const Icon(Icons.add), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/visual/views/camera/share_image_editor.view.dart b/lib/src/visual/views/camera/share_image_editor.view.dart index 12f8d36a..334611d9 100644 --- a/lib/src/visual/views/camera/share_image_editor.view.dart +++ b/lib/src/visual/views/camera/share_image_editor.view.dart @@ -15,6 +15,7 @@ import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection.view.dart'; +import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/story_target_selector.comp.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor_components/discard_media_dialog.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor_components/display_time_picker.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart'; @@ -67,6 +68,7 @@ class _ShareImageEditorView extends State { bool loadingImage = true; bool isDisposed = false; HashSet selectedGroupIds = HashSet(); + final StoryTarget storyTarget = StoryTarget(); double pixelRatio = 1; VideoPlayerController? videoController; @@ -352,6 +354,7 @@ class _ShareImageEditorView extends State { Future _toggleRequiresAuth() async { await mediaService.setRequiresAuth(!media.requiresAuthentication); selectedGroupIds = HashSet(); + storyTarget.clear(); if (mounted) setState(() {}); } @@ -361,6 +364,7 @@ class _ShareImageEditorView extends State { if (widget.sendToGroup == null) { selectedGroupIds.clear(); } + storyTarget.clear(); if (enabled) { _displayLimitBeforeWidget = media.displayLimitInMilliseconds; @@ -467,6 +471,7 @@ class _ShareImageEditorView extends State { mediaFileService: mediaService, additionalData: getAdditionalData(), sendToWidget: _sendToWidget, + storyTarget: storyTarget, ), ), ); diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index 55c642ba..82b8d104 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:clock/clock.dart'; import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -8,6 +9,7 @@ import 'package:provider/provider.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/key_verification.dao.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; @@ -17,10 +19,12 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/connection_status.comp.dart'; import 'package:twonly/src/visual/components/notification_badge.comp.dart'; +import 'package:twonly/src/visual/components/story_preview.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/news_btn.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart'; @@ -46,6 +50,9 @@ class _ChatListViewState extends State StreamSubscription>? _verificationSub; StreamSubscription>? _contactGroupsSub; StreamSubscription>? _chatContactGroupsSub; + StreamSubscription>? _storiesSub; + StreamSubscription>? _ownStoriesSub; + final StoryExpiryTimer _ownStoryExpiry = StoryExpiryTimer(); Timer? _typingUpdateTimer; final Set _precachedMediaIds = {}; List _groupsNotPinned = []; @@ -61,6 +68,8 @@ class _ChatListViewState extends State Map _verificationByGroup = {}; Map> _contactGroupsByUser = {}; Map> _contactGroupsByGroup = {}; + Map> _storiesByGroup = {}; + List _ownStories = []; final ValueNotifier _hasContacts = ValueNotifier(false); bool _loading = true; @@ -231,6 +240,42 @@ class _ChatListViewState extends State } setState(() => _contactGroupsByGroup = contactGroups); }); + _storiesSub = twonlyDB.storiesDao.watchReceivedStoryItems().listen((items) { + if (!mounted) return; + final byGroup = >{}; + for (final item in items) { + byGroup.putIfAbsent(item.message.groupId, () => []).add(item); + } + setState(() => _storiesByGroup = byGroup); + }); + _ownStoriesSub = twonlyDB.storiesDao.watchOwnStoryItems().listen((items) { + if (!mounted) return; + setState(() => _ownStories = items); + _scheduleOwnStoryExpiry(); + }); + } + + List get _activeOwnStories { + final now = clock.now(); + return _ownStories.where((item) => item.isActiveAt(now)).toList(); + } + + /// Puts the avatar back when the oldest own story item runs out. + void _scheduleOwnStoryExpiry() { + final oldest = _activeOwnStories.firstOrNull; + if (oldest == null) { + _ownStoryExpiry.cancel(); + return; + } + _ownStoryExpiry.schedule( + postedAt: oldest.postedAt, + now: clock.now(), + onExpired: () { + if (!mounted) return; + setState(() {}); + _scheduleOwnStoryExpiry(); + }, + ); } /// Labels of a chat: those of the contact for direct chats, those of the @@ -265,6 +310,9 @@ class _ChatListViewState extends State _verificationSub?.cancel(); _contactGroupsSub?.cancel(); _chatContactGroupsSub?.cancel(); + _storiesSub?.cancel(); + _ownStoriesSub?.cancel(); + _ownStoryExpiry.cancel(); super.dispose(); } @@ -307,12 +355,22 @@ class _ChatListViewState extends State children: [ ConnectionStatusComp( child: GestureDetector( + // The profile is where the user's own story is followed. onTap: () => context.push(Routes.settingsProfile), - child: AvatarIcon( - myAvatar: true, - fontSize: 14, - color: context.color.onSurface.withAlpha(20), - ), + child: switch (_activeOwnStories.lastOrNull) { + // While the user has a story, it replaces their avatar. + final OwnStoryItem newest => StoryPreview( + mediaFile: newest.mediaFile, + width: 28, + height: 28, + circle: true, + ), + null => AvatarIcon( + myAvatar: true, + fontSize: 14, + color: context.color.onSurface.withAlpha(20), + ), + }, ), ), const SizedBox(width: 10), @@ -446,6 +504,7 @@ class _ChatListViewState extends State useSharedSummary: true, verificationStatus: _verificationByGroup[group.groupId], contactGroups: _contactGroupsFor(group), + storyItems: _storiesByGroup[group.groupId] ?? const [], ); } @@ -475,6 +534,7 @@ class _ChatListViewState extends State useSharedSummary: true, verificationStatus: _verificationByGroup[group.groupId], contactGroups: _contactGroupsFor(group), + storyItems: _storiesByGroup[group.groupId] ?? const [], ); }, ), diff --git a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart index f8bea14e..2975882d 100644 --- a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; @@ -7,6 +8,7 @@ import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/key_verification.dao.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; @@ -15,11 +17,13 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/contact_groups.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart'; +import 'package:twonly/src/visual/components/story_preview.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/context_menu/group.context_menu.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/last_message_time.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/typing_indicator_subtitle.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart'; class GroupListItemComp extends StatefulWidget { const GroupListItemComp({ @@ -33,6 +37,7 @@ class GroupListItemComp extends StatefulWidget { this.useSharedSummary = false, this.verificationStatus, this.contactGroups = const [], + this.storyItems, super.key, }); final Group group; @@ -46,6 +51,9 @@ class GroupListItemComp extends StatefulWidget { final VerificationStatus? verificationStatus; final List contactGroups; + /// The contact's story items in a direct chat. Null subscribes itself. + final List? storyItems; + @override State createState() => _UserListItem(); } @@ -63,6 +71,9 @@ class _UserListItem extends State { StreamSubscription>? _lastMediaFilesStream; Contact? _directContact; StreamSubscription>? _directContactStream; + List _storyItems = const []; + StreamSubscription>? _storyItemsStream; + final StoryExpiryTimer _storyExpiry = StoryExpiryTimer(); List _previewMessages = []; final List _previewMediaFiles = []; @@ -75,6 +86,8 @@ class _UserListItem extends State { _applyContacts(); _lastReaction = widget.lastReaction; _lastMessage = widget.lastMessage; + _storyItems = widget.storyItems ?? const []; + _scheduleStoryExpiry(); _applyMediaFiles(); if (widget.useSharedSummary) { _updateState(widget.lastMessage, widget.unopenedMessages ?? const []); @@ -95,6 +108,11 @@ class _UserListItem extends State { _updateState(widget.lastMessage, widget.unopenedMessages ?? const []); } if (widget.mediaFiles != oldWidget.mediaFiles) _applyMediaFiles(); + if (widget.storyItems != null && + widget.storyItems != oldWidget.storyItems) { + _storyItems = widget.storyItems!; + _scheduleStoryExpiry(); + } } void _applyContacts() { @@ -120,10 +138,22 @@ class _UserListItem extends State { _lastMessageStream?.cancel(); _lastMediaFilesStream?.cancel(); _directContactStream?.cancel(); + _storyItemsStream?.cancel(); + _storyExpiry.cancel(); super.dispose(); } Future initStreams() async { + if (widget.storyItems == null && widget.group.isDirectChat) { + _storyItemsStream = twonlyDB.storiesDao + .watchReceivedStoryItemsForGroup(widget.group.groupId) + .listen((items) { + if (!mounted) return; + setState(() => _storyItems = items); + _scheduleStoryExpiry(); + }); + } + if (!widget.useSharedSummary) { final lastMsgStream = await twonlyDB.messagesDao.watchLastMessage( widget.group.groupId, @@ -319,6 +349,72 @@ class _UserListItem extends State { ); } + List get _activeStoryItems { + final now = clock.now(); + return _storyItems.where((item) => item.isActiveAt(now)).toList(); + } + + /// Redraws the row when its oldest story item runs out, so the preview + /// does not outlive the story. + void _scheduleStoryExpiry() { + final oldest = _activeStoryItems.firstOrNull; + if (oldest == null) { + _storyExpiry.cancel(); + return; + } + _storyExpiry.schedule( + postedAt: oldest.postedAt, + now: clock.now(), + onExpired: () { + if (!mounted) return; + setState(() {}); + _scheduleStoryExpiry(); + }, + ); + } + + /// While the contact has a story, it stands in for their avatar: the item + /// that plays first, with the avatar small in its corner. Once all of it + /// has been seen it is drawn softer. + Widget _avatar() { + final avatar = AvatarIcon(group: widget.group, contacts: widget.contacts); + if (!widget.group.isDirectChat) return avatar; + final active = _activeStoryItems; + if (active.isEmpty) return avatar; + final unseen = active.where((item) => !item.seen); + final shown = unseen.firstOrNull ?? active.last; + return StoryPreview( + mediaFile: shown.mediaFile, + width: 40, + height: 40, + faded: unseen.isEmpty, + badge: AvatarIcon( + group: widget.group, + contacts: widget.contacts, + fontSize: 9, + ), + ); + } + + /// A contact's avatar opens their story while they have one, and their + /// profile otherwise. + Future _onAvatarTap() async { + if (!widget.group.isDirectChat) { + await context.push(Routes.profileGroup(widget.group.groupId)); + return; + } + final active = _activeStoryItems; + if (active.isNotEmpty) { + await context.push(Routes.chatsStory(active.first.senderId)); + return; + } + final contacts = await twonlyDB.groupsDao.getGroupContact( + widget.group.groupId, + ); + if (!mounted || contacts.isEmpty) return; + await context.push(Routes.profileContact(contacts.first.userId)); + } + @override Widget build(BuildContext context) { return StreamBuilder( @@ -414,21 +510,8 @@ class _UserListItem extends State { ], ), leading: GestureDetector( - onTap: () async { - if (widget.group.isDirectChat) { - final contacts = await twonlyDB.groupsDao.getGroupContact( - widget.group.groupId, - ); - if (!context.mounted) return; - await context.push( - Routes.profileContact(contacts.first.userId), - ); - return; - } else { - await context.push(Routes.profileGroup(widget.group.groupId)); - } - }, - child: AvatarIcon(group: widget.group, contacts: widget.contacts), + onTap: _onAvatarTap, + child: _avatar(), ), trailing: (widget.group.leftGroup || _receiverDeletedAccount) ? null diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 6913aba0..091ddfb8 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; +import 'package:clock/clock.dart'; import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -10,6 +11,7 @@ import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/memory_item.model.dart'; @@ -563,6 +565,25 @@ class _ChatMessagesViewState extends State } Future scrollToMessage(String messageId) async { + // A quoted story is not in the chat to scroll to; it opens the story at + // that item instead, for as long as the story is still there. + if (!_data.messagesById.containsKey(messageId)) { + final quoted = await twonlyDB.messagesDao + .getMessageById(messageId) + .getSingleOrNull(); + if (quoted != null && quoted.isStory && !quoted.mediaStored) { + final alive = + quoted.mediaId != null && + clock.now().isBefore(quoted.createdAt.add(storyLifetime)); + if (!alive || !mounted) return; + final senderId = quoted.senderId; + await context.push( + senderId == null ? Routes.chatsOwnStory : Routes.chatsStory(senderId), + extra: quoted.mediaId, + ); + return; + } + } var index = _data.chatItems.indexWhere( (x) => x.isMessage && x.message!.messageId == messageId, ); diff --git a/lib/src/visual/views/chats/chat_messages_components/response_container.dart b/lib/src/visual/views/chats/chat_messages_components/response_container.dart index 6db21344..d793f8cf 100644 --- a/lib/src/visual/views/chats/chat_messages_components/response_container.dart +++ b/lib/src/visual/views/chats/chat_messages_components/response_container.dart @@ -1,6 +1,8 @@ +import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; @@ -80,7 +82,9 @@ class ResponseContainer extends StatelessWidget { contact: quotedMessage?.senderId == null ? null : contactsById?[quotedMessage!.senderId], - useSharedData: useSharedData, + // A quote the loaded page does not hold, such as a story + // the chat never lists, is looked up by the preview. + useSharedData: useSharedData && quotedMessage != null, showBorder: false, showLeftBorder: false, ), @@ -169,6 +173,17 @@ class _ResponsePreviewState extends State { if (mounted) setState(() {}); } + /// Whether the quoted story can still be shown. Once it expired, or its + /// sender took it down, only the fact that it was a story remains. + bool get _storyAlive { + final message = _message; + if (message == null || !message.isStory) return false; + if (message.mediaStored) return true; + return message.mediaId != null && + _mediaService != null && + clock.now().isBefore(message.createdAt.add(storyLifetime)); + } + @override Widget build(BuildContext context) { String? subtitle; @@ -219,21 +234,28 @@ class _ResponsePreviewState extends State { _username = context.lang.you; } + if (_message!.isStory) { + subtitle = _storyAlive ? context.lang.story : context.lang.storyExpired; + } + color = getMessageColor(_message!.senderId != null); } final hasImage = _message != null && - _message!.mediaStored && + (_message!.mediaStored || _storyAlive) && _mediaService != null && _mediaService!.mediaFile.type != MediaType.audio; Widget? imageWidget; if (hasImage) { final isVideo = _mediaService!.mediaFile.type == MediaType.video; + // A story nobody saved is still in its temporary file. final pathToCheck = isVideo ? _mediaService!.thumbnailPath - : _mediaService!.storedPath; + : _message!.mediaStored + ? _mediaService!.storedPath + : _mediaService!.tempPath; imageWidget = Container( height: 40, width: 40, diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index 9dcdecae..bcacc0d7 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -10,6 +10,7 @@ import 'package:screen_protector/screen_protector.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart' show DownloadState, MediaType; import 'package:twonly/src/database/twonly.db.dart'; @@ -28,15 +29,40 @@ import 'package:twonly/src/visual/views/chats/media_viewer_components/keyboard_d import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_bottom_navigation.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/own_story_bottom_bar.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart'; import 'package:video_player/video_player.dart'; +/// What a story viewer plays: one contact's active story, or the user's own. +class StorySource { + const StorySource.contact(int this.contactId, {this.initialMediaId}) + : own = false; + const StorySource.own({this.initialMediaId}) : contactId = null, own = true; + + final int? contactId; + final bool own; + + /// The item to start at. Without one a contact's story starts at the first + /// item not seen yet, and the user's own story at its oldest item. + final String? initialMediaId; +} + class MediaViewerView extends StatefulWidget { - const MediaViewerView(this.group, {super.key, this.initialMessage}); - final Group group; + const MediaViewerView(Group this.group, {super.key, this.initialMessage}) + : story = null; + + /// Plays a story instead of a chat's unopened media. Items replay until + /// they expire, so nothing here is removed after it was shown. + const MediaViewerView.story(StorySource this.story, {super.key}) + : group = null, + initialMessage = null; + + final Group? group; final Message? initialMessage; + final StorySource? story; @override State createState() => _MediaViewerViewState(); } @@ -44,6 +70,7 @@ class MediaViewerView extends StatefulWidget { class _MediaViewerViewState extends State { Timer? nextMediaTimer; Timer? progressTimer; + final StoryExpiryTimer _storyExpiryTimer = StoryExpiryTimer(); bool showShortReactions = false; double mediaViewerDistanceFromBottom = 0; @@ -79,10 +106,23 @@ class _MediaViewerViewState extends State { bool _isTransitioning = false; + /// The chat replies go to. For a contact's story it is the direct chat the + /// story rows are filed under, looked up once the story is loaded. + Group? _group; + + bool get _isStory => widget.story != null; + bool get _isOwnStory => widget.story?.own ?? false; + @override void initState() { super.initState(); - _currentMediaSender = widget.group.groupName; + _group = widget.group; + _currentMediaSender = widget.group?.groupName ?? ''; + + if (_isStory) { + unawaited(_loadStory()); + return; + } if (widget.initialMessage != null && !widget.initialMessage!.isWidgetMedia) { @@ -96,6 +136,7 @@ class _MediaViewerViewState extends State { void dispose() { nextMediaTimer?.cancel(); progressTimer?.cancel(); + _storyExpiryTimer.cancel(); _subscription?.cancel(); downloadStateListener?.cancel(); progress.dispose(); @@ -106,14 +147,16 @@ class _MediaViewerViewState extends State { // Persist draft message on close final draftText = textMessageController.text; - unawaited( - twonlyDB.groupsDao.updateGroup( - widget.group.groupId, - GroupsCompanion( - draftMessage: Value(draftText.isEmpty ? null : draftText), + if (!_isStory) { + unawaited( + twonlyDB.groupsDao.updateGroup( + widget.group!.groupId, + GroupsCompanion( + draftMessage: Value(draftText.isEmpty ? null : draftText), + ), ), - ), - ); + ); + } textMessageController.dispose(); super.dispose(); @@ -132,9 +175,54 @@ class _MediaViewerViewState extends State { final Mutex _messageUpdateLock = Mutex(); + /// A story is a fixed playlist: its items are all there when it is opened, + /// and new ones show up the next time it is. + Future _loadStory() async { + final story = widget.story!; + final now = clock.now(); + var playlist = []; + var start = 0; + if (story.own) { + final items = await twonlyDB.storiesDao.watchOwnStoryItems().first; + for (final item in items.where((item) => item.isActiveAt(now))) { + final rows = await twonlyDB.messagesDao.getMessagesByMediaId( + item.mediaFile.mediaId, + ); + final row = rows + .where((row) => row.isStory && row.senderId == null) + .firstOrNull; + if (row != null) playlist.add(row); + } + if (mounted) _currentMediaSender = context.lang.storyMine; + } else { + final contactId = story.contactId!; + playlist = (await twonlyDB.storiesDao.getReceivedStoryItems(contactId)) + .where((item) => item.isActiveAt(now)) + .map((item) => item.message) + .toList(); + start = playlist.indexWhere((message) => message.openedAt == null); + if (start < 0) start = 0; + final contact = await twonlyDB.contactsDao.getContactById(contactId); + if (contact != null) _currentMediaSender = getContactDisplayName(contact); + if (playlist.isNotEmpty) { + _group = await twonlyDB.groupsDao.getGroup(playlist.first.groupId); + } + } + final initial = story.initialMediaId; + if (initial != null) { + final index = playlist.indexWhere((m) => m.mediaId == initial); + if (index >= 0) start = index; + } + if (!mounted) return; + setState(() { + allMediaFiles = playlist.isEmpty ? [] : playlist.sublist(start); + }); + await loadAndDownloadCurrentMedia(); + } + Future listenForUnopenedMedia(bool firstRun) async { _subscription = twonlyDB.messagesDao - .watchMediaNotOpened(widget.group.groupId) + .watchMediaNotOpened(widget.group!.groupId) .listen((messages) async { await _messageUpdateLock.protect(() async { for (final msg in messages) { @@ -193,8 +281,9 @@ class _MediaViewerViewState extends State { _isTransitioning = true; try { - /// Remove the current media file in case it is not set to unlimited - if (currentMedia != null) { + /// Remove the current media file in case it is not set to unlimited. + /// A story replays until it expires, whatever the file says. + if (currentMedia != null && !_isStory) { if (!imageSaved && currentMedia!.mediaFile.displayLimitInMilliseconds != null) { await currentMedia!.fullMediaRemoval(); @@ -207,9 +296,12 @@ class _MediaViewerViewState extends State { nextMediaTimer?.cancel(); progressTimer?.cancel(); + _storyExpiryTimer.cancel(); - if (allMediaFiles.isEmpty) { - final group = await twonlyDB.groupsDao.getGroup(widget.group.groupId); + if (allMediaFiles.isEmpty && _isStory) { + if (mounted) Navigator.pop(context); + } else if (allMediaFiles.isEmpty) { + final group = await twonlyDB.groupsDao.getGroup(widget.group!.groupId); if (mounted) { if (group != null && group.draftMessage != null && @@ -232,6 +324,24 @@ class _MediaViewerViewState extends State { Future loadAndDownloadCurrentMedia({bool showTwonly = false}) async { if (!mounted || !context.mounted) return; + if (_isStory) { + final now = clock.now(); + while (allMediaFiles.isNotEmpty && + !allMediaFiles.first.createdAt.add(storyLifetime).isAfter(now)) { + allMediaFiles.removeAt(0); + } + if (allMediaFiles.isEmpty) { + Navigator.pop(context); + return; + } + _storyExpiryTimer.schedule( + postedAt: allMediaFiles.first.createdAt, + now: now, + onExpired: () { + if (mounted) unawaited(advanceToNextMediaOrExit()); + }, + ); + } if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) { return advanceToNextMediaOrExit(); } @@ -270,7 +380,9 @@ class _MediaViewerViewState extends State { await advanceToNextMediaOrExit(); return; } - if (updated.downloadState != DownloadState.ready) { + // The user's own story plays from the file they sent; there is + // nothing to download. + if (!_isOwnStory && updated.downloadState != DownloadState.ready) { setState(() { _showDownloadingLoader = true; }); @@ -354,20 +466,25 @@ class _MediaViewerViewState extends State { Log.warn( 'Temp media file not found for media ID: ${currentMediaLocal.mediaFile.mediaId}', ); - await RustApi.requestMediaReupload( - mediaId: currentMediaLocal.mediaFile.mediaId, - ); + if (!_isOwnStory) { + await RustApi.requestMediaReupload( + mediaId: currentMediaLocal.mediaFile.mediaId, + ); + } return advanceToNextMediaOrExit(); } // The server can now delete the encrypted bytes, as the user has successfully opened it. - Log.info( - 'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}', - ); - unawaitedRustCall( - RustApi.downloadDone(token: currentMediaLocal.mediaFile.downloadToken!), - 'downloadDone', - ); + final downloadToken = currentMediaLocal.mediaFile.downloadToken; + if (downloadToken != null) { + Log.info( + 'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}', + ); + unawaitedRustCall( + RustApi.downloadDone(token: downloadToken), + 'downloadDone', + ); + } if (currentMediaLocal.mediaFile.type == MediaType.video) { await _setupVideoPlayer(currentMediaLocal); @@ -383,21 +500,25 @@ class _MediaViewerViewState extends State { } Future _updateSenderInfo() async { - if (currentMessage == null || widget.group.isDirectChat) return; + final group = widget.group; + if (currentMessage == null || group == null || group.isDirectChat) return; final sender = await twonlyDB.contactsDao.getContactById( currentMessage!.senderId!, ); if (mounted && sender != null) { _currentMediaSender = - '${getContactDisplayName(sender)} (${widget.group.groupName})'; + '${getContactDisplayName(sender)} (${group.groupName})'; } } Future _notifyMessageOpened(MediaFileService mediaLocal) async { - if (currentMessage == null) return; + if (currentMessage == null || _isOwnStory) return; + // A story item is reported the first time only; replays are not news. + if (_isStory && currentMessage!.openedAt != null) return; var markAsOpenMessageIDs = [currentMessage!.messageId]; - if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened && + if (!_isStory && + userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened && mediaLocal.mediaFile.storedFileHash != null) { final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash( mediaLocal.mediaFile.storedFileHash!, @@ -432,7 +553,7 @@ class _MediaViewerViewState extends State { ); await controller.setLooping( - mediaLocal.mediaFile.displayLimitInMilliseconds == null, + _isStory || mediaLocal.mediaFile.displayLimitInMilliseconds == null, ); if (!mounted) { @@ -458,7 +579,8 @@ class _MediaViewerViewState extends State { progress.value = 1 - ctrl.value.position.inSeconds / duration; } - if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) { + if (!_isStory && + mediaLocal.mediaFile.displayLimitInMilliseconds != null) { if (ctrl.value.position == ctrl.value.duration) { advanceToNextMediaOrExit(); } @@ -482,7 +604,8 @@ class _MediaViewerViewState extends State { } void _setupImageTimer(MediaFileService mediaLocal) { - if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) { + // A story image stays until the viewer moves on. + if (!_isStory && mediaLocal.mediaFile.displayLimitInMilliseconds != null) { canBeSeenUntil = clock.now().add( Duration( milliseconds: mediaLocal.mediaFile.displayLimitInMilliseconds!, @@ -534,7 +657,7 @@ class _MediaViewerViewState extends State { ), ); await RustApi.sendEncryptedContentToGroup( - groupId: widget.group.groupId, + groupId: msg.groupId, content: pb.EncryptedContent( mediaUpdate: pb.EncryptedContent_MediaUpdate( type: pb.EncryptedContent_MediaUpdate_Type.STORED, @@ -561,6 +684,13 @@ class _MediaViewerViewState extends State { } Widget bottomNavigation() { + if (_isOwnStory) { + return OwnStoryBottomBar( + key: mediaWidgetKey, + mediaFile: currentMedia?.mediaFile, + onDeleted: advanceToNextMediaOrExit, + ); + } return MediaViewerBottomNavigationBar( key: mediaWidgetKey, currentMedia: currentMedia, @@ -589,15 +719,18 @@ class _MediaViewerViewState extends State { progressTimer?.cancel(); await videoController?.pause(); if (!mounted) return; + final group = _group; + if (group == null) return; await Navigator.push( context, MaterialPageRoute( builder: (context) { - return CameraSendToView(widget.group); + return CameraSendToView(group); }, ), ); if (mounted && + !_isStory && currentMedia!.mediaFile.displayLimitInMilliseconds != null) { await advanceToNextMediaOrExit(); } else { @@ -624,7 +757,7 @@ class _MediaViewerViewState extends State { if (textMessageController.text.isNotEmpty) { unawaitedRustCall( RustApi.insertAndSendText( - groupId: widget.group.groupId, + groupId: currentMessage!.groupId, text: textMessageController.text, quoteMessageId: currentMessage!.messageId, ), @@ -700,7 +833,10 @@ class _MediaViewerViewState extends State { if (currentMedia != null && currentMedia?.mediaFile.downloadState != DownloadState.ready) Positioned.fill(child: _loader()), - if (canBeSeenUntil != null || progress.value >= 0) + // A story image has no clock to show; a video still shows how far + // it has played. + if ((canBeSeenUntil != null || progress.value >= 0) && + !(_isStory && videoController == null)) Positioned( right: 20, top: 27, @@ -755,13 +891,16 @@ class _MediaViewerViewState extends State { ), if (currentMessage != null) AdditionalMessageContent(currentMessage!), - if (currentMedia != null) + if (currentMedia != null && !_isOwnStory) ReactionButtons( show: showShortReactions, textInputFocused: showSendTextMessageInput, mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom, - groupId: widget.group.groupId, + groupId: currentMessage!.groupId, messageId: currentMessage!.messageId, + // A story row never shows in the chat, so a reaction to it + // goes there as a message quoting the story instead. + asQuotedText: _isStory, emojiKey: emojiKey, hide: () { setState(() { diff --git a/lib/src/visual/views/chats/media_viewer_components/emoji_reactions_row.comp.dart b/lib/src/visual/views/chats/media_viewer_components/emoji_reactions_row.comp.dart index 3c1dd70b..c7446a26 100644 --- a/lib/src/visual/views/chats/media_viewer_components/emoji_reactions_row.comp.dart +++ b/lib/src/visual/views/chats/media_viewer_components/emoji_reactions_row.comp.dart @@ -21,8 +21,17 @@ Offset getGlobalOffset(GlobalKey targetKey) { Future sendReaction( String groupId, String messageId, - String emoji, -) async { + String emoji, { + bool asQuotedText = false, +}) async { + if (asQuotedText) { + await RustApi.insertAndSendText( + groupId: groupId, + text: emoji, + quoteMessageId: messageId, + ); + return; + } await twonlyDB.reactionsDao.updateMyReaction( messageId, emoji, @@ -49,6 +58,7 @@ class EmojiReactionWidget extends StatefulWidget { required this.show, required this.emoji, required this.emojiKey, + this.asQuotedText = false, super.key, }); final String messageId; @@ -57,6 +67,7 @@ class EmojiReactionWidget extends StatefulWidget { final bool show; final String emoji; final GlobalKey emojiKey; + final bool asQuotedText; @override State createState() => _EmojiReactionWidgetState(); @@ -70,7 +81,12 @@ class _EmojiReactionWidgetState extends State { return GestureDetector( key: _targetKey, onTap: () async { - await sendReaction(widget.groupId, widget.messageId, widget.emoji); + await sendReaction( + widget.groupId, + widget.messageId, + widget.emoji, + asQuotedText: widget.asQuotedText, + ); widget.emojiKey.currentState?.spawn( getGlobalOffset(_targetKey), widget.emoji, diff --git a/lib/src/visual/views/chats/media_viewer_components/own_story_bottom_bar.comp.dart b/lib/src/visual/views/chats/media_viewer_components/own_story_bottom_bar.comp.dart new file mode 100644 index 00000000..2a15aee9 --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/own_story_bottom_bar.comp.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/visual/elements/my_icon_button.element.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/story_viewers.bottom_sheet.dart'; + +/// What the media viewer offers under one of the user's own story items: who +/// has seen it, and taking it down early. +class OwnStoryBottomBar extends StatelessWidget { + const OwnStoryBottomBar({ + required this.mediaFile, + required this.onDeleted, + super.key, + }); + + final MediaFile? mediaFile; + + /// Called once the item is gone, so the viewer can move on. + final Future Function() onDeleted; + + Future _showViewers(BuildContext context, MediaFile media) async { + await showModalBottomSheet( + context: context, + backgroundColor: Colors.black, + builder: (context) => StoryViewersBottomSheet(mediaFile: media), + ); + } + + Future _delete(BuildContext context, MediaFile media) async { + if (await confirmAndDeleteStoryItem(context, media)) await onDeleted(); + } + + @override + Widget build(BuildContext context) { + final media = mediaFile; + if (media == null) return const SizedBox(height: 55); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + StreamBuilder>( + stream: twonlyDB.storiesDao.watchStoryViewers(media.mediaId), + builder: (context, snapshot) { + final seen = (snapshot.data ?? const []) + .where((viewer) => viewer.openedAt != null) + .length; + return MyIconButton( + variant: MyIconButtonVariant.secondary, + onPressed: () => _showViewers(context, media), + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FaIcon(FontAwesomeIcons.eye, size: 18), + const SizedBox(width: 8), + Text('$seen'), + ], + ), + ); + }, + ), + const SizedBox(width: 10), + MyIconButton( + variant: MyIconButtonVariant.secondary, + onPressed: canDeleteStoryItem(media) + ? () => _delete(context, media) + : null, + icon: const FaIcon(FontAwesomeIcons.trash, size: 18), + ), + ], + ); + } +} diff --git a/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart b/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart index a1a18e33..106e5b9f 100644 --- a/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart +++ b/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart @@ -21,6 +21,7 @@ class ReactionButtons extends StatefulWidget { required this.groupId, required this.emojiKey, required this.hide, + this.asQuotedText = false, super.key, }); @@ -32,6 +33,9 @@ class ReactionButtons extends StatefulWidget { final String groupId; final void Function() hide; + /// Send the emoji as a message quoting the target instead of a reaction. + final bool asQuotedText; + @override State createState() => _ReactionButtonsState(); } @@ -123,6 +127,7 @@ class _ReactionButtonsState extends State { show: _renderAnimations, emoji: emoji as String, emojiKey: widget.emojiKey, + asQuotedText: widget.asQuotedText, ), ) .toList(), @@ -140,6 +145,7 @@ class _ReactionButtonsState extends State { show: _renderAnimations, emoji: emoji, emojiKey: widget.emojiKey, + asQuotedText: widget.asQuotedText, ), ), GestureDetector( @@ -160,6 +166,7 @@ class _ReactionButtonsState extends State { widget.groupId, widget.messageId, layer.text, + asQuotedText: widget.asQuotedText, ); widget.emojiKey.currentState?.spawn( getGlobalOffset(_keyEmojiPicker), diff --git a/lib/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart b/lib/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart new file mode 100644 index 00000000..d91baa0a --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:twonly/src/database/daos/stories.dao.dart'; + +typedef StoryTimerFactory = + Timer Function( + Duration duration, + void Function() callback, + ); + +class StoryExpiryTimer { + StoryExpiryTimer({StoryTimerFactory? timerFactory}) + : _timerFactory = timerFactory ?? Timer.new; + + final StoryTimerFactory _timerFactory; + Timer? _timer; + + void schedule({ + required DateTime postedAt, + required DateTime now, + required void Function() onExpired, + }) { + cancel(); + final remaining = postedAt.add(storyLifetime).difference(now); + _timer = _timerFactory( + remaining.isNegative ? Duration.zero : remaining, + onExpired, + ); + } + + void cancel() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/lib/src/visual/views/chats/media_viewer_components/story_viewers.bottom_sheet.dart b/lib/src/visual/views/chats/media_viewer_components/story_viewers.bottom_sheet.dart new file mode 100644 index 00000000..f375e8e5 --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/story_viewers.bottom_sheet.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/core/bridge/stories.dart' as stories; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/alert.dialog.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; +import 'package:twonly/src/visual/components/snackbar.dart'; + +/// Asks, then takes one of the user's story items down for everybody. +/// Returns whether it is gone. +Future confirmAndDeleteStoryItem( + BuildContext context, + MediaFile mediaFile, +) async { + final confirmed = await showAlertDialog( + context, + context.lang.storyDeleteTitle, + context.lang.storyDeleteBody, + customOk: context.lang.storyDelete, + ); + if (!confirmed) return false; + try { + await stories.deleteStoryItem(mediaId: mediaFile.mediaId); + return true; + } catch (e) { + Log.error('Could not delete the story item: $e'); + if (context.mounted) showSnackbar(context, context.lang.storyDeleteFailed); + return false; + } +} + +/// A delete could overtake an upload still on its way and the item would then +/// arrive after all, so it waits until the upload is done. +bool canDeleteStoryItem(MediaFile mediaFile) => + mediaFile.uploadState == UploadState.uploaded; + +/// Who has viewed one of the user's story items. Recipients who have not +/// looked at it yet, and how far delivery got, are not the question here. +class StoryViewersBottomSheet extends StatefulWidget { + const StoryViewersBottomSheet({required this.mediaFile, super.key}); + + final MediaFile mediaFile; + + @override + State createState() => + _StoryViewersBottomSheetState(); +} + +class _StoryViewersBottomSheetState extends State { + StreamSubscription>? _viewersSub; + List _viewers = []; + + @override + void initState() { + super.initState(); + _viewersSub = twonlyDB.storiesDao + .watchStoryViewers(widget.mediaFile.mediaId) + .listen((viewers) { + if (!mounted) return; + setState( + () => _viewers = viewers + .where((viewer) => viewer.openedAt != null) + .toList(), + ); + }); + } + + @override + void dispose() { + _viewersSub?.cancel(); + super.dispose(); + } + + Future _delete() async { + final deleted = await confirmAndDeleteStoryItem(context, widget.mediaFile); + if (deleted && mounted) Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return Container( + height: 400, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + color: context.color.surface, + boxShadow: const [ + BoxShadow( + blurRadius: 10.9, + color: Color.fromRGBO(0, 0, 0, 0.1), + ), + ], + ), + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 20, bottom: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + color: Colors.grey, + ), + height: 3, + width: 60, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Row( + children: [ + const SizedBox(width: 48), + Expanded( + child: Text( + context.lang.storyViews(_viewers.length), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + IconButton( + tooltip: context.lang.storyDelete, + onPressed: canDeleteStoryItem(widget.mediaFile) + ? _delete + : null, + icon: const FaIcon(FontAwesomeIcons.trash, size: 16), + ), + ], + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 30), + children: [ + for (final viewer in _viewers) + Padding( + key: ValueKey(viewer.message.messageId), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + AvatarIcon(contactId: viewer.contact.userId), + const SizedBox(width: 10), + Expanded( + child: Text( + getContactDisplayName(viewer.contact), + style: const TextStyle(fontSize: 17), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + friendlyDateTime(context, viewer.openedAt!), + style: TextStyle( + fontSize: 12, + color: context.color.onSurface.withAlpha(150), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/visual/views/chats/message_info.view.dart b/lib/src/visual/views/chats/message_info.view.dart index 75574e8a..ffbc7338 100644 --- a/lib/src/visual/views/chats/message_info.view.dart +++ b/lib/src/visual/views/chats/message_info.view.dart @@ -6,13 +6,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/locator.dart'; -import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; +import 'package:twonly/src/visual/components/recipient_status_row.comp.dart'; import 'package:twonly/src/visual/elements/better_list_title.element.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/message_history.bottom_sheet.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart'; @@ -164,38 +163,11 @@ class _MessageInfoViewState extends State { } columns.add( - Padding( + RecipientStatusRow( key: ValueKey(groupMember.$1.contactId), - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - AvatarIcon( - contactId: groupMember.$2.userId, - fontSize: 15, - ), - const SizedBox(width: 6), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - getContactDisplayName(groupMember.$2), - style: const TextStyle(fontSize: 17), - ), - ], - ), - ), - Column( - children: [ - Text( - friendlyDateTime(context, actionAt), - style: const TextStyle(fontSize: 12), - ), - Text(actionTypeText), - ], - ), - ], - ), + contact: groupMember.$2, + status: actionTypeText, + at: actionAt, ), ); } diff --git a/lib/src/visual/views/contact/contact_group_settings.view.dart b/lib/src/visual/views/contact/contact_group_settings.view.dart index a21371c3..fa05b871 100644 --- a/lib/src/visual/views/contact/contact_group_settings.view.dart +++ b/lib/src/visual/views/contact/contact_group_settings.view.dart @@ -21,11 +21,13 @@ class ContactGroupSettingsView extends StatefulWidget { const ContactGroupSettingsView({ this.contactGroup, this.initialShowAsShortcut = false, + this.initialShareStories = false, super.key, }); final ContactGroup? contactGroup; final bool initialShowAsShortcut; + final bool initialShareStories; @override State createState() => @@ -69,6 +71,7 @@ class _ContactGroupSettingsViewState extends State { late int _textColor; late bool _showAsShortcut; late bool _showAsLabel; + late bool _shareStories; String? _emoji; bool _saving = false; String _memberFilter = ''; @@ -96,6 +99,7 @@ class _ContactGroupSettingsViewState extends State { _showAsShortcut = contactGroup?.showAsShortcut ?? widget.initialShowAsShortcut; _showAsLabel = contactGroup?.showAsLabel ?? !widget.initialShowAsShortcut; + _shareStories = contactGroup?.shareStories ?? widget.initialShareStories; _emoji = contactGroup?.emoji; if (contactGroup != null) unawaited(_loadWidgetUsage(contactGroup.id)); @@ -226,6 +230,7 @@ class _ContactGroupSettingsViewState extends State { backgroundColor: _backgroundColor, showAsShortcut: _showAsShortcut, showAsLabel: _showAsLabel, + shareStories: _shareStories, ); if (_isEditing) { await twonlyDB.contactGroupsDao.updateContactGroup( @@ -236,6 +241,7 @@ class _ContactGroupSettingsViewState extends State { backgroundColor: _backgroundColor, showAsShortcut: _showAsShortcut, showAsLabel: _showAsLabel, + shareStories: _shareStories, ); } await twonlyDB.contactGroupsDao.replaceMembers( @@ -528,6 +534,13 @@ class _ContactGroupSettingsViewState extends State { value: _showAsShortcut, onChanged: (value) => setState(() => _showAsShortcut = value), ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: Text(context.lang.contactGroupShareStories), + subtitle: Text(context.lang.contactGroupShareStoriesSubtitle), + value: _shareStories, + onChanged: (value) => setState(() => _shareStories = value), + ), if (_showAsShortcut) ListTile( contentPadding: EdgeInsets.zero, diff --git a/lib/src/visual/views/contact/select_contact_groups.view.dart b/lib/src/visual/views/contact/select_contact_groups.view.dart index 29a56070..eabf56a6 100644 --- a/lib/src/visual/views/contact/select_contact_groups.view.dart +++ b/lib/src/visual/views/contact/select_contact_groups.view.dart @@ -8,6 +8,7 @@ import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/contact_groups.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart'; class SelectContactGroupsView extends StatefulWidget { @@ -193,10 +194,17 @@ class _SelectContactGroupsViewState extends State { Expanded( child: _contactGroups.isEmpty ? Center( - child: FilledButton.icon( + child: MyButton( + variant: MyButtonVariant.primaryDense, onPressed: _openSettings, - icon: const Icon(Icons.add), - label: Text(context.lang.createContactGroup), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add), + const SizedBox(width: 8), + Text(context.lang.createContactGroup), + ], + ), ), ) : ListView.builder( @@ -209,6 +217,8 @@ class _SelectContactGroupsViewState extends State { context.lang.contactGroupLabelFeature, if (contactGroup.showAsShortcut) context.lang.contactGroupShortcutFeature, + if (contactGroup.shareStories) + context.lang.contactGroupStoriesFeature, ].join(' · '); return ListTile( contentPadding: const EdgeInsets.symmetric( diff --git a/lib/src/visual/views/home.view.dart b/lib/src/visual/views/home.view.dart index 8ba09987..c95ce704 100644 --- a/lib/src/visual/views/home.view.dart +++ b/lib/src/visual/views/home.view.dart @@ -41,6 +41,9 @@ class HomeViewState extends State with WidgetsBindingObserver { double _offsetFromOne = 0; bool _isBottomNavVisible = true; Timer? _disableCameraTimer; + + /// Whether [_openEditor] has the editor open over this view. + bool _editorOpen = false; bool _startPreloading = false; final MainCameraController _mainCameraController = MainCameraController(); @@ -153,14 +156,7 @@ class HomeViewState extends State with WidgetsBindingObserver { return; } file.copySync(newMediaService.originalPath.path); - if (!mounted) return; - - await context.navPush( - ShareImageEditorView( - mediaFileService: newMediaService, - sharedFromGallery: true, - ), - ); + await _openEditor(newMediaService); }); if (HomeViewState.pendingSharedLink != null) { @@ -269,19 +265,31 @@ class HomeViewState extends State with WidgetsBindingObserver { final draftMedia = await twonlyDB.mediaFilesDao.getDraftMediaFile(); if (draftMedia != null) { - if (!mounted) return; - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ShareImageEditorView( - mediaFileService: MediaFileService(draftMedia), - sharedFromGallery: true, - ), - ), - ); + await _openEditor(MediaFileService(draftMedia)); } } + /// Opens the editor on media that did not come from the camera, such as a + /// restored draft. The camera may already be running, started with the + /// app, and would keep running underneath the editor for as long as it is + /// open. It is closed, and the camera page counts as hidden until the + /// editor is left, so nothing starts it again in the meantime; the camera + /// page starts it itself once it shows again. + Future _openEditor(MediaFileService mediaFileService) async { + if (!mounted) return; + _disableCameraTimer?.cancel(); + setState(() => _editorOpen = true); + await _mainCameraController.closeCamera(); + if (!mounted) return; + await context.navPush( + ShareImageEditorView( + mediaFileService: mediaFileService, + sharedFromGallery: true, + ), + ); + if (mounted) setState(() => _editorOpen = false); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -453,7 +461,9 @@ class HomeViewState extends State with WidgetsBindingObserver { child: CameraPreviewControllerView( mainController: _mainCameraController, isVisible: - ((1 - (_offsetRatio * 4) % 1) == 1) && _activePageIdx == 1, + ((1 - (_offsetRatio * 4) % 1) == 1) && + _activePageIdx == 1 && + !_editorOpen, ), ), ), diff --git a/lib/src/visual/views/settings/notification.view.dart b/lib/src/visual/views/settings/notification.view.dart index bfa60c9b..9b3db3b0 100644 --- a/lib/src/visual/views/settings/notification.view.dart +++ b/lib/src/visual/views/settings/notification.view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:twonly/locator.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart'; +import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; @@ -30,6 +32,13 @@ class _NotificationViewState extends State { } } + Future _toggleStoryNotifications() async { + await UserService.update((u) { + u.storyNotifications = !u.storyNotifications; + }); + setState(() {}); + } + Future _resetTokens() async { setState(() { _isLoadingReset = true; @@ -60,6 +69,15 @@ class _NotificationViewState extends State { subtitle: Text(context.lang.settingsNotifyPermissionDesc), onTap: openAppSettings, ), + ListTile( + title: Text(context.lang.settingsNotifyStories), + subtitle: Text(context.lang.settingsNotifyStoriesSubtitle), + onTap: _toggleStoryNotifications, + trailing: Switch.adaptive( + value: userService.currentUser.storyNotifications, + onChanged: (_) => _toggleStoryNotifications(), + ), + ), if (_hasNotificationPermission == true) ListTile( title: Text(context.lang.settingsNotifyResetTitle), diff --git a/lib/src/visual/views/settings/profile/own_story_strip.comp.dart b/lib/src/visual/views/settings/profile/own_story_strip.comp.dart new file mode 100644 index 00000000..73c10a69 --- /dev/null +++ b/lib/src/visual/views/settings/profile/own_story_strip.comp.dart @@ -0,0 +1,148 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/story_preview.comp.dart'; +import 'package:twonly/src/visual/views/chats/chat_list_components/last_message_time.comp.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/story_viewers.bottom_sheet.dart'; + +/// The user's story on their profile: every live item with how long ago it +/// was posted and how many have viewed it. Tapping one lists those viewers. +/// Nothing is shown while the user has no story. +class OwnStoryStrip extends StatefulWidget { + const OwnStoryStrip({super.key}); + + @override + State createState() => _OwnStoryStripState(); +} + +class _OwnStoryStripState extends State { + StreamSubscription>? _itemsSub; + List _items = []; + final StoryExpiryTimer _expiry = StoryExpiryTimer(); + + @override + void initState() { + super.initState(); + _itemsSub = twonlyDB.storiesDao.watchOwnStoryItems().listen((items) { + if (!mounted) return; + setState(() => _items = items); + _scheduleExpiry(); + }); + } + + @override + void dispose() { + _itemsSub?.cancel(); + _expiry.cancel(); + super.dispose(); + } + + List get _active { + final now = clock.now(); + return _items.where((item) => item.isActiveAt(now)).toList(); + } + + void _scheduleExpiry() { + final oldest = _active.firstOrNull; + if (oldest == null) { + _expiry.cancel(); + return; + } + _expiry.schedule( + postedAt: oldest.postedAt, + now: clock.now(), + onExpired: () { + if (!mounted) return; + setState(() {}); + _scheduleExpiry(); + }, + ); + } + + Future _showViewers(OwnStoryItem item) async { + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => StoryViewersBottomSheet(mediaFile: item.mediaFile), + ); + } + + @override + Widget build(BuildContext context) { + // Newest first: the item posted last is the one people see next to the + // user's name. + final items = _active.reversed.toList(); + if (items.isEmpty) return const SizedBox.shrink(); + final secondary = TextStyle( + fontSize: 12, + color: context.color.onSurface.withAlpha(170), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 10), + child: Text( + context.lang.storyMine, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + SizedBox( + height: 150, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final item = items[index]; + return GestureDetector( + key: ValueKey(item.mediaFile.mediaId), + onTap: () => _showViewers(item), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StoryPreview( + mediaFile: item.mediaFile, + width: 72, + height: 110, + ), + const SizedBox(height: 6), + SizedBox( + width: 72, + child: Row( + children: [ + DefaultTextStyle.merge( + style: secondary, + child: LastMessageTimeComp( + dateTime: item.postedAt, + ), + ), + const Spacer(), + FaIcon( + FontAwesomeIcons.eye, + size: 11, + color: secondary.color, + ), + const SizedBox(width: 4), + Text('${item.viewerCount}', style: secondary), + ], + ), + ), + ], + ), + ); + }, + ), + ), + const SizedBox(height: 10), + ], + ); + } +} diff --git a/lib/src/visual/views/settings/profile/profile.view.dart b/lib/src/visual/views/settings/profile/profile.view.dart index 30f4383f..a232f233 100644 --- a/lib/src/visual/views/settings/profile/profile.view.dart +++ b/lib/src/visual/views/settings/profile/profile.view.dart @@ -13,6 +13,7 @@ import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/elements/better_list_title.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/groups/group.view.dart'; +import 'package:twonly/src/visual/views/settings/profile/own_story_strip.comp.dart'; class ProfileView extends StatefulWidget { const ProfileView({super.key}); @@ -125,6 +126,7 @@ class _ProfileViewState extends State { ), ), const SizedBox(height: 20), + const OwnStoryStrip(), const Divider(), BetterListTile( leading: const Padding( diff --git a/rust/.sqlx/query-0e41d6bc329927e36f9f47028c8fed85d00a117be9b731f6641f0d0bb6ae210d.json b/rust/.sqlx/query-0e41d6bc329927e36f9f47028c8fed85d00a117be9b731f6641f0d0bb6ae210d.json new file mode 100644 index 00000000..72713933 --- /dev/null +++ b/rust/.sqlx/query-0e41d6bc329927e36f9f47028c8fed85d00a117be9b731f6641f0d0bb6ae210d.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(\n SELECT 1 FROM messages\n WHERE message_id = ? AND sender_id IS NULL AND is_story = 1\n ) AS \"own_story!: bool\"", + "describe": { + "columns": [ + { + "name": "own_story!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "0e41d6bc329927e36f9f47028c8fed85d00a117be9b731f6641f0d0bb6ae210d" +} diff --git a/rust/.sqlx/query-0fd03444e490940d3c953bf98935ad6711dbce8dddffb266d0681123cd910a1b.json b/rust/.sqlx/query-0fd03444e490940d3c953bf98935ad6711dbce8dddffb266d0681123cd910a1b.json new file mode 100644 index 00000000..9e3070bb --- /dev/null +++ b/rust/.sqlx/query-0fd03444e490940d3c953bf98935ad6711dbce8dddffb266d0681123cd910a1b.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT stored AS \"stored: bool\" FROM media_files WHERE media_id = ?", + "describe": { + "columns": [ + { + "name": "stored: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "stored" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "0fd03444e490940d3c953bf98935ad6711dbce8dddffb266d0681123cd910a1b" +} diff --git a/rust/.sqlx/query-3166edaa454ae4d5fb69012963ccc4a468ae0e07d5b61ffee329308c6f3e02c5.json b/rust/.sqlx/query-3166edaa454ae4d5fb69012963ccc4a468ae0e07d5b61ffee329308c6f3e02c5.json new file mode 100644 index 00000000..a6704152 --- /dev/null +++ b/rust/.sqlx/query-3166edaa454ae4d5fb69012963ccc4a468ae0e07d5b61ffee329308c6f3e02c5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE groups\n SET last_text_or_media_at = MAX(COALESCE(last_text_or_media_at, 0), ?)\n WHERE group_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "3166edaa454ae4d5fb69012963ccc4a468ae0e07d5b61ffee329308c6f3e02c5" +} diff --git a/rust/.sqlx/query-4add98e13f0f12e3d852e0b23183d57a2501ebf7df4e3daecef0c43e9ec5f0ab.json b/rust/.sqlx/query-4add98e13f0f12e3d852e0b23183d57a2501ebf7df4e3daecef0c43e9ec5f0ab.json new file mode 100644 index 00000000..6c5356da --- /dev/null +++ b/rust/.sqlx/query-4add98e13f0f12e3d852e0b23183d57a2501ebf7df4e3daecef0c43e9ec5f0ab.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM messages WHERE quotes_message_id = ?) AS \"quoted!: bool\"", + "describe": { + "columns": [ + { + "name": "quoted!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "4add98e13f0f12e3d852e0b23183d57a2501ebf7df4e3daecef0c43e9ec5f0ab" +} diff --git a/rust/.sqlx/query-f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52.json b/rust/.sqlx/query-649d1ed9bcc18ad5ea496f3c10992e31190deaea130850e4c286267d053158e7.json similarity index 88% rename from rust/.sqlx/query-f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52.json rename to rust/.sqlx/query-649d1ed9bcc18ad5ea496f3c10992e31190deaea130850e4c286267d053158e7.json index d3c1795b..9785a9a8 100644 --- a/rust/.sqlx/query-f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52.json +++ b/rust/.sqlx/query-649d1ed9bcc18ad5ea496f3c10992e31190deaea130850e4c286267d053158e7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT message_id, sender_id AS \"sender_id!: i64\" FROM messages\n WHERE media_id = ? AND sender_id IS NOT NULL\n AND (opened_at IS NULL OR is_widget_media = 1)", + "query": "SELECT message_id, sender_id AS \"sender_id!: i64\" FROM messages\n WHERE media_id = ? AND sender_id IS NOT NULL\n AND (opened_at IS NULL OR is_widget_media = 1 OR is_story = 1)", "describe": { "columns": [ { @@ -34,5 +34,5 @@ true ] }, - "hash": "f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52" + "hash": "649d1ed9bcc18ad5ea496f3c10992e31190deaea130850e4c286267d053158e7" } diff --git a/rust/.sqlx/query-65fc2abdab1eaba3ac336697a775215ff7a2a94f050c20630c69b384e2a59ef7.json b/rust/.sqlx/query-65fc2abdab1eaba3ac336697a775215ff7a2a94f050c20630c69b384e2a59ef7.json new file mode 100644 index 00000000..56f9b5e0 --- /dev/null +++ b/rust/.sqlx/query-65fc2abdab1eaba3ac336697a775215ff7a2a94f050c20630c69b384e2a59ef7.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(\n SELECT 1 FROM groups WHERE group_id = ? AND last_text_or_media_at > ?\n ) AS \"recent!: bool\"", + "describe": { + "columns": [ + { + "name": "recent!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "65fc2abdab1eaba3ac336697a775215ff7a2a94f050c20630c69b384e2a59ef7" +} diff --git a/rust/.sqlx/query-ac056007bc89c8e50751106922a524b9cad10b7855af3dacda23269af2c6f3ee.json b/rust/.sqlx/query-ac056007bc89c8e50751106922a524b9cad10b7855af3dacda23269af2c6f3ee.json new file mode 100644 index 00000000..3c930495 --- /dev/null +++ b/rust/.sqlx/query-ac056007bc89c8e50751106922a524b9cad10b7855af3dacda23269af2c6f3ee.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE notification_outbox SET cleared_at = ?\n WHERE message_id = ? AND kind = 'story' AND cleared_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "ac056007bc89c8e50751106922a524b9cad10b7855af3dacda23269af2c6f3ee" +} diff --git a/rust/.sqlx/query-b47ac224594691c9a878877f8815d870d6d887679ffa0297c67ae0c4bf490f0e.json b/rust/.sqlx/query-b47ac224594691c9a878877f8815d870d6d887679ffa0297c67ae0c4bf490f0e.json new file mode 100644 index 00000000..b537677e --- /dev/null +++ b/rust/.sqlx/query-b47ac224594691c9a878877f8815d870d6d887679ffa0297c67ae0c4bf490f0e.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT user_id FROM contacts\n WHERE accepted = 1 AND blocked = 0 AND deleted_by_user = 0 AND account_deleted = 0", + "describe": { + "columns": [ + { + "name": "user_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "contacts", + "name": "user_id" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "b47ac224594691c9a878877f8815d870d6d887679ffa0297c67ae0c4bf490f0e" +} diff --git a/rust/.sqlx/query-b984da6db7f2c197bb89566f2fcc00ae3982ca57a4ad5183e23db672bfc7ca79.json b/rust/.sqlx/query-b984da6db7f2c197bb89566f2fcc00ae3982ca57a4ad5183e23db672bfc7ca79.json new file mode 100644 index 00000000..04692737 --- /dev/null +++ b/rust/.sqlx/query-b984da6db7f2c197bb89566f2fcc00ae3982ca57a4ad5183e23db672bfc7ca79.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM groups WHERE group_id = ?) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "b984da6db7f2c197bb89566f2fcc00ae3982ca57a4ad5183e23db672bfc7ca79" +} diff --git a/rust/.sqlx/query-b9b8a9700410dfd75de46b561feb26cb00d1aaa27aa23d9d43fbc582972b5978.json b/rust/.sqlx/query-b9b8a9700410dfd75de46b561feb26cb00d1aaa27aa23d9d43fbc582972b5978.json new file mode 100644 index 00000000..420847df --- /dev/null +++ b/rust/.sqlx/query-b9b8a9700410dfd75de46b561feb26cb00d1aaa27aa23d9d43fbc582972b5978.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT cgm.user_id AS \"user_id!: i64\"\n FROM contact_group_members cgm\n JOIN contact_groups cg ON cg.id = cgm.contact_group_id\n WHERE cg.id = ? AND cg.share_stories = 1 AND cgm.user_id IS NOT NULL", + "describe": { + "columns": [ + { + "name": "user_id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "contact_group_members", + "name": "user_id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "b9b8a9700410dfd75de46b561feb26cb00d1aaa27aa23d9d43fbc582972b5978" +} diff --git a/rust/.sqlx/query-ba5a9b12e344593eda59f627b8b7eb124eaa4aeb981ee3ee37cfa7e2297c1987.json b/rust/.sqlx/query-ba5a9b12e344593eda59f627b8b7eb124eaa4aeb981ee3ee37cfa7e2297c1987.json new file mode 100644 index 00000000..4ca921cd --- /dev/null +++ b/rust/.sqlx/query-ba5a9b12e344593eda59f627b8b7eb124eaa4aeb981ee3ee37cfa7e2297c1987.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT gm.contact_id\n FROM contact_group_members cgm\n JOIN contact_groups cg ON cg.id = cgm.contact_group_id\n JOIN group_members gm ON gm.group_id = cgm.group_id\n WHERE cg.id = ? AND cg.share_stories = 1 AND cgm.group_id IS NOT NULL\n AND (gm.member_state IS NULL OR gm.member_state != 'leftGroup')", + "describe": { + "columns": [ + { + "name": "contact_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "group_members", + "name": "contact_id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "ba5a9b12e344593eda59f627b8b7eb124eaa4aeb981ee3ee37cfa7e2297c1987" +} diff --git a/rust/.sqlx/query-bf36937c8900ffce1ce941ae44bc0d7e48ca176f3b2e7df9f94559dd240c6eef.json b/rust/.sqlx/query-bf36937c8900ffce1ce941ae44bc0d7e48ca176f3b2e7df9f94559dd240c6eef.json new file mode 100644 index 00000000..fc5a4cb4 --- /dev/null +++ b/rust/.sqlx/query-bf36937c8900ffce1ce941ae44bc0d7e48ca176f3b2e7df9f94559dd240c6eef.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT message_id FROM messages\n WHERE is_story = 1 AND media_stored = 0 AND created_at <= ?", + "describe": { + "columns": [ + { + "name": "message_id", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "messages", + "name": "message_id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "bf36937c8900ffce1ce941ae44bc0d7e48ca176f3b2e7df9f94559dd240c6eef" +} diff --git a/rust/.sqlx/query-013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e.json b/rust/.sqlx/query-c44fdd0c6ac77ef4b604dd0008f69db96edbcac9c3f3dac16d8c0945b5451d9e.json similarity index 57% rename from rust/.sqlx/query-013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e.json rename to rust/.sqlx/query-c44fdd0c6ac77ef4b604dd0008f69db96edbcac9c3f3dac16d8c0945b5451d9e.json index f0473359..3480f133 100644 --- a/rust/.sqlx/query-013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e.json +++ b/rust/.sqlx/query-c44fdd0c6ac77ef4b604dd0008f69db96edbcac9c3f3dac16d8c0945b5451d9e.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "\n INSERT INTO messages(\n group_id,\n message_id,\n sender_id,\n type,\n media_id,\n additional_message_data,\n quotes_message_id,\n is_widget_media,\n opened_at,\n opened_by_all,\n created_at\n ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?)\n ", + "query": "\n INSERT INTO messages(\n group_id,\n message_id,\n sender_id,\n type,\n media_id,\n additional_message_data,\n quotes_message_id,\n is_widget_media,\n is_story,\n opened_at,\n opened_by_all,\n created_at\n ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?, ?)\n ", "describe": { "columns": [], "parameters": { - "Right": 10 + "Right": 11 }, "nullable": [] }, - "hash": "013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e" + "hash": "c44fdd0c6ac77ef4b604dd0008f69db96edbcac9c3f3dac16d8c0945b5451d9e" } diff --git a/rust/.sqlx/query-dc47c3260c79b988e6c74af7bc6e38c197cc6922c101f455ef3d0fd4f644c993.json b/rust/.sqlx/query-dc47c3260c79b988e6c74af7bc6e38c197cc6922c101f455ef3d0fd4f644c993.json new file mode 100644 index 00000000..b012dbb0 --- /dev/null +++ b/rust/.sqlx/query-dc47c3260c79b988e6c74af7bc6e38c197cc6922c101f455ef3d0fd4f644c993.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT message_id, group_id FROM messages\n WHERE media_id = ? AND is_story = 1 AND sender_id IS NULL AND media_stored = 0", + "describe": { + "columns": [ + { + "name": "message_id", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "messages", + "name": "message_id" + } + } + }, + { + "name": "group_id", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "messages", + "name": "group_id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "dc47c3260c79b988e6c74af7bc6e38c197cc6922c101f455ef3d0fd4f644c993" +} diff --git a/rust/.sqlx/query-e28362e3911929f66746631d9dff86002d9900e14f71dacde26f008af96c18af.json b/rust/.sqlx/query-e28362e3911929f66746631d9dff86002d9900e14f71dacde26f008af96c18af.json new file mode 100644 index 00000000..57edbe6f --- /dev/null +++ b/rust/.sqlx/query-e28362e3911929f66746631d9dff86002d9900e14f71dacde26f008af96c18af.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE notification_outbox SET cleared_at = ?\n WHERE message_id = ? AND kind = 'story' AND cleared_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "e28362e3911929f66746631d9dff86002d9900e14f71dacde26f008af96c18af" +} diff --git a/rust/.sqlx/query-e2f68ef88181ad5b8f35a9be2d56ed2c3f4e0cac87d58e18fc27704a4d7688dc.json b/rust/.sqlx/query-e2f68ef88181ad5b8f35a9be2d56ed2c3f4e0cac87d58e18fc27704a4d7688dc.json new file mode 100644 index 00000000..cbcbe86b --- /dev/null +++ b/rust/.sqlx/query-e2f68ef88181ad5b8f35a9be2d56ed2c3f4e0cac87d58e18fc27704a4d7688dc.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT type AS media_type, stored, is_draft_media FROM media_files\n WHERE media_id = ?\n AND NOT EXISTS (SELECT 1 FROM messages WHERE media_id = media_files.media_id)", + "describe": { + "columns": [ + { + "name": "media_type", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "media_files", + "name": "type" + } + } + }, + { + "name": "stored", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "stored" + } + } + }, + { + "name": "is_draft_media", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "is_draft_media" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "e2f68ef88181ad5b8f35a9be2d56ed2c3f4e0cac87d58e18fc27704a4d7688dc" +} diff --git a/rust/.sqlx/query-e3684506954963d336412dea8579fa2586dcb4474d19758b2b41284a105161d9.json b/rust/.sqlx/query-e3684506954963d336412dea8579fa2586dcb4474d19758b2b41284a105161d9.json new file mode 100644 index 00000000..fc1f9843 --- /dev/null +++ b/rust/.sqlx/query-e3684506954963d336412dea8579fa2586dcb4474d19758b2b41284a105161d9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE messages SET media_id = NULL WHERE message_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "e3684506954963d336412dea8579fa2586dcb4474d19758b2b41284a105161d9" +} diff --git a/rust/src/api/messages/incoming.rs b/rust/src/api/messages/incoming.rs index a4baf703..022e98cf 100644 --- a/rust/src/api/messages/incoming.rs +++ b/rust/src/api/messages/incoming.rs @@ -11,6 +11,7 @@ mod media; pub mod messages; mod reaction; pub mod recovery; +pub(crate) mod story; mod text_message; mod typing_indicator; mod user_discovery; @@ -495,7 +496,7 @@ pub async fn handle_decoded_server_message( return Ok(()); } - let mut sends_error_response = false; + let mut withhold_delivery_receipt = false; let mut rebuild_session = false; match message_type { @@ -520,7 +521,7 @@ pub async fn handle_decoded_server_message( } // Never acknowledged: the message was not read, so the sender must // not be told it was delivered. - sends_error_response = true; + withhold_delivery_receipt = true; } Type::CiphertextV2 => { let decrypted = match resumed_plaintext { @@ -577,7 +578,7 @@ pub async fn handle_decoded_server_message( "invalid decrypted client content: {error}" )) })?; - sends_error_response = process_encrypted_or_queue_error( + withhold_delivery_receipt = process_encrypted_or_queue_error( ctx, &mut t, from_user_id, @@ -585,7 +586,7 @@ pub async fn handle_decoded_server_message( content, ) .await? - .is_some(); + == Delivery::Withhold; } // The ratchet has already consumed this message, so it was // handled and the peer is only missing the answer. Falling @@ -611,7 +612,7 @@ pub async fn handle_decoded_server_message( error_type as i32, ) .await?; - sends_error_response = true; + withhold_delivery_receipt = true; } } } @@ -627,7 +628,7 @@ pub async fn handle_decoded_server_message( Type::TestNotification => {} } - if is_encrypted_message & !sends_error_response { + if is_encrypted_message & !withhold_delivery_receipt { queue_sender_delivery_receipt(&mut t, from_user_id, &message.receipt_id).await?; } @@ -720,6 +721,16 @@ async fn rebuild_session(ctx: &Arc, from_user_id: i64) -> Result<()> { Ok(()) } +/// Whether the sender learns that a handled message arrived. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Delivery { + /// Queue the sender delivery receipt, which ends the sender's retries. + Ack, + /// Tell the sender nothing, either because an error response goes back + /// instead or because the content is ignored outright. + Withhold, +} + /// Dispatches already-decrypted client-to-client content to its concrete /// feature module. Transport decoding and Signal decryption do not belong in /// this dispatcher. @@ -729,7 +740,15 @@ pub(crate) async fn handle_encrypted( from_user_id: i64, receipt_id: &str, content: proto::EncryptedContent, -) -> Result<()> { +) -> Result { + // A story that arrives after its 24 hours is ignored whole: no row, no + // notification and no receipt. The sender drops its receipt when the story + // expires there, which is what ends its retransmissions. + if content.story.as_ref().is_some_and(story::is_expired) { + tracing::info!("Ignoring a story that arrived after it expired"); + return Ok(Delivery::Withhold); + } + let notification_content = content.clone(); handle_encrypted_inner(ctx, t, from_user_id, receipt_id, content).await?; crate::services::notifications::record_incoming_event( @@ -739,7 +758,8 @@ pub(crate) async fn handle_encrypted( receipt_id, ¬ification_content, ) - .await + .await?; + Ok(Delivery::Ack) } async fn handle_encrypted_inner( @@ -793,6 +813,10 @@ async fn handle_encrypted_inner( return media::handle_media_update(ctx, t, from_user_id, update).await; } + if let Some(item) = content.story { + return story::handle_story(ctx, t, from_user_id, item).await; + } + if let Some(error) = content.error_messages { return errors::handle_error_message( ctx, @@ -925,7 +949,7 @@ async fn handle_encrypted_inner( } if let Some(media) = content.media { - return media::handle_media(ctx, t, from_user_id, &group_id, media).await; + return media::handle_media(ctx, t, from_user_id, &group_id, media, false).await; } if let Some(reaction) = content.reaction { diff --git a/rust/src/api/messages/incoming/media.rs b/rust/src/api/messages/incoming/media.rs index fb3ae3e2..0bb7e04d 100644 --- a/rust/src/api/messages/incoming/media.rs +++ b/rust/src/api/messages/incoming/media.rs @@ -57,6 +57,7 @@ pub(crate) async fn handle_media( from_user_id: i64, group_id: &str, media: encrypted_content::Media, + is_story: bool, ) -> Result<()> { let media_type = MediaType::try_from(media.r#type)?; let widget_only = media.widget_only == Some(true); @@ -225,10 +226,11 @@ pub(crate) async fn handle_media( additional_message_data, quotes_message_id, is_widget_media, + is_story, opened_at, opened_by_all, created_at - ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?, ?) "#, group_id, media.sender_message_id, @@ -237,6 +239,7 @@ pub(crate) async fn handle_media( media.additional_message_data, media.quote_message_id, widget_only, + is_story, opened_at, opened_by_all, timestamp, @@ -244,9 +247,12 @@ pub(crate) async fn handle_media( .execute(&mut **t) .await?; - if !widget_only { + // A story is not an exchange in the chat it is filed under: it neither + // moves the chat up nor feeds its flames. + if !widget_only && !is_story { Group::increase_last_message_exchange(t, group_id, timestamp).await?; Group::record_media_exchange(t, group_id, true, timestamp).await?; + Group::record_text_or_media(t, group_id, timestamp).await?; } spawn_media_download(ctx, media_id); @@ -300,7 +306,18 @@ pub(crate) async fn handle_media_update( .execute(&mut **t) .await?; - spawn_media_store(ctx, media_id.clone()); + // Every recipient of a story shares one media file here, so the + // second person saving it must not export it to the gallery again. + let already_stored = sqlx::query_scalar!( + r#"SELECT stored AS "stored: bool" FROM media_files WHERE media_id = ?"#, + media_id, + ) + .fetch_optional(&mut **t) + .await? + .unwrap_or(false); + if !already_stored { + spawn_media_store(ctx, media_id.clone()); + } } MediaUpdateType::DecryptionError => { // The upload state and the requester list are owned by the reupload diff --git a/rust/src/api/messages/incoming/messages.rs b/rust/src/api/messages/incoming/messages.rs index b7259316..73d8fcf9 100644 --- a/rust/src/api/messages/incoming/messages.rs +++ b/rust/src/api/messages/incoming/messages.rs @@ -3,7 +3,7 @@ * */ -use super::handle_encrypted; +use super::{handle_encrypted, Delivery}; use crate::api::proto::client::{self as proto}; use crate::api::Server; use crate::bridge::api::ServerResult; @@ -167,16 +167,16 @@ pub(crate) async fn process_encrypted_or_queue_error( from_user_id: i64, receipt_id: &str, content: proto::EncryptedContent, -) -> Result> { +) -> Result { let group_id = content.group_id.clone(); match handle_encrypted(ctx, t, from_user_id, receipt_id, content).await { - Ok(()) => Ok(None), + Ok(delivery) => Ok(delivery), Err(error) => { let description = error.to_string(); if description.contains("group join arrived before") { queue_retry_control(t, from_user_id, receipt_id).await?; - return Ok(Some(receipt_id.to_owned())); + return Ok(Delivery::Withhold); } let error_type = if description.contains("not a member") { @@ -221,7 +221,7 @@ pub(crate) async fn process_encrypted_or_queue_error( .execute(&mut **t) .await?; - Ok(Some(outgoing_receipt_id)) + Ok(Delivery::Withhold) } } } diff --git a/rust/src/api/messages/incoming/story.rs b/rust/src/api/messages/incoming/story.rs new file mode 100644 index 00000000..d026e1c0 --- /dev/null +++ b/rust/src/api/messages/incoming/story.rs @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +use super::media; +use crate::api::proto::client::encrypted_content; +use crate::context::Context; +use crate::database::app::tables::{Contact, Group}; +use crate::error::{Result, TwonlyError}; +use crate::services::stories::STORY_LIFETIME_SECONDS; +use crate::utils::milliseconds_to_seconds; +use encrypted_content::media::Type as MediaType; +use sqlx::{Sqlite, Transaction}; +use std::sync::Arc; + +/// When the story was posted, capped at `now`: a timestamp from the future +/// would otherwise keep the story around for longer than its 24 hours. +pub(crate) fn posted_at(story: &encrypted_content::Story, now: i64) -> i64 { + story + .media + .as_ref() + .map_or(0, |media| milliseconds_to_seconds(media.timestamp)) + .min(now) +} + +pub(crate) fn is_expired(story: &encrypted_content::Story) -> bool { + let now = chrono::Utc::now().timestamp(); + posted_at(story, now) + STORY_LIFETIME_SECONDS <= now +} + +/// Files a story item under the 1:1 chat with its sender, where it stays +/// hidden until it is saved. Expired items never get here; see +/// `handle_encrypted`. +pub(crate) async fn handle_story( + ctx: &Arc, + t: &mut Transaction<'_, Sqlite>, + from_user_id: i64, + story: encrypted_content::Story, +) -> Result<()> { + let posted_at = posted_at(&story, chrono::Utc::now().timestamp()); + let Some(mut media) = story.media else { + return Err(TwonlyError::UnprocessableContent( + "story carries no media".into(), + )); + }; + if !matches!( + MediaType::try_from(media.r#type)?, + MediaType::Image | MediaType::Video + ) { + tracing::warn!( + from_user_id, + "dropping a story that is not an image or video" + ); + return Ok(()); + } + + let Some(contact) = Contact::get_contact_by_id(t, from_user_id) + .await? + .filter(|contact| { + contact.accepted != 0 && contact.blocked == 0 && contact.deleted_by_user == 0 + }) + else { + tracing::info!( + from_user_id, + "dropping a story from someone who is not a contact" + ); + return Ok(()); + }; + + let group_id = Group::direct_chat_id(ctx.user_id().await?, from_user_id); + let has_chat = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM groups WHERE group_id = ?) AS "exists!: bool""#, + group_id, + ) + .fetch_one(&mut **t) + .await?; + if !has_chat { + Group::create_direct_chat(ctx, t, contact).await?; + } + + // A story replays until it expires, for everybody it was sent to, so + // whatever limits the sender's editor attached do not apply to it. + media.display_limit_in_milliseconds = None; + media.requires_authentication = false; + media.widget_only = None; + media.quote_message_id = None; + media.timestamp = posted_at.saturating_mul(1_000); + + media::handle_media(ctx, t, from_user_id, &group_id, media, true).await +} + +#[cfg(test)] +mod tests { + use crate::api::messages::incoming::{handle_encrypted, Delivery}; + use crate::api::proto::client::{encrypted_content, EncryptedContent}; + use crate::context::Context; + use crate::database::app::tables::Group; + use crate::error::Result; + use crate::services::stories::{ + STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS, STORY_LIFETIME_SECONDS, + }; + use crate::user_config::UserConfig; + use std::sync::Arc; + + async fn context(story_notifications: bool) -> Result<(tempfile::TempDir, Arc)> { + let directory = tempfile::tempdir()?; + let ctx = + Context::init_for_testing(directory.path().join("db"), directory.path().join("data")) + .await?; + UserConfig::save_json( + &ctx, + &format!( + r#"{{"userId":42,"username":"ben","displayName":"Ben","storyNotifications":{story_notifications}}}"# + ), + )?; + ctx.inject_test_user_id(42).await?; + let database = ctx.app_db.read().await.clone(); + sqlx::query("INSERT INTO contacts(user_id, username, accepted) VALUES (7, 'anna', 1)") + .execute(&database.pool) + .await?; + sqlx::query("INSERT INTO contacts(user_id, username) VALUES (8, 'stranger')") + .execute(&database.pool) + .await?; + Ok((directory, ctx)) + } + + /// The 1:1 chat with contact 7, last used for a text or media message at + /// `last_text_or_media_at`. + async fn chat_with_anna(ctx: &Context, last_text_or_media_at: Option) -> Result { + let chat = Group::direct_chat_id(42, 7); + let database = ctx.app_db.read().await.clone(); + sqlx::query( + "INSERT INTO groups(group_id, group_name, is_direct_chat, joined_group, last_text_or_media_at) VALUES (?, 'Anna', 1, 1, ?)", + ) + .bind(&chat) + .bind(last_text_or_media_at) + .execute(&database.pool) + .await?; + sqlx::query("INSERT INTO group_members(group_id, contact_id) VALUES (?, 7)") + .bind(&chat) + .execute(&database.pool) + .await?; + Ok(chat) + } + + fn story(message_id: &str, posted_at: i64, notify: bool) -> EncryptedContent { + EncryptedContent { + story: Some(encrypted_content::Story { + media: Some(encrypted_content::Media { + sender_message_id: message_id.into(), + r#type: encrypted_content::media::Type::Image as i32, + // Whatever the editor had set, a story replays. + display_limit_in_milliseconds: Some(5_000), + timestamp: posted_at * 1_000, + download_token: Some(vec![1; 32]), + encryption_key: Some(vec![2; 32]), + encryption_mac: Some(vec![3; 16]), + encryption_nonce: Some(vec![4; 12]), + ..Default::default() + }), + notify, + }), + ..Default::default() + } + } + + async fn receive( + ctx: &Arc, + from: i64, + receipt_id: &str, + content: EncryptedContent, + ) -> Result { + let database = ctx.app_db.read().await.clone(); + let mut t = database.pool.begin().await?; + let delivery = handle_encrypted(ctx, &mut t, from, receipt_id, content).await?; + t.commit().await?; + Ok(delivery) + } + + async fn outbox_kinds(ctx: &Context) -> Result> { + let database = ctx.app_db.read().await.clone(); + Ok( + sqlx::query_scalar("SELECT kind FROM notification_outbox ORDER BY event_id") + .fetch_all(&database.pool) + .await?, + ) + } + + #[tokio::test] + async fn a_story_is_filed_hidden_in_the_direct_chat_and_announced_once() -> Result<()> { + let (_directory, ctx) = context(true).await?; + let now = chrono::Utc::now().timestamp(); + chat_with_anna(&ctx, Some(now - 3600)).await?; + + assert_eq!( + receive(&ctx, 7, "r1", story("s1", now, true)).await?, + Delivery::Ack + ); + assert_eq!( + receive(&ctx, 7, "r2", story("s2", now, false)).await?, + Delivery::Ack + ); + + let database = ctx.app_db.read().await.clone(); + let rows: Vec<(String, String, i64)> = sqlx::query_as( + "SELECT message_id, group_id, is_story FROM messages ORDER BY message_id", + ) + .fetch_all(&database.pool) + .await?; + let chat = Group::direct_chat_id(42, 7); + assert_eq!( + rows, + vec![ + ("s1".into(), chat.clone(), 1), + ("s2".into(), chat.clone(), 1) + ] + ); + let limits: Vec> = + sqlx::query_scalar("SELECT display_limit_in_milliseconds FROM media_files") + .fetch_all(&database.pool) + .await?; + assert_eq!(limits, vec![None, None]); + // Not an exchange in the chat: no flames, no media counted. + let counter: i64 = + sqlx::query_scalar("SELECT total_media_counter FROM groups WHERE group_id = ?") + .bind(&chat) + .fetch_one(&database.pool) + .await?; + assert_eq!(counter, 0); + // Only the item the sender woke this device for is announced. + assert_eq!(outbox_kinds(&ctx).await?, vec!["story".to_owned()]); + Ok(()) + } + + #[tokio::test] + async fn a_muted_receiver_files_the_story_without_announcing_it() -> Result<()> { + let (_directory, ctx) = context(false).await?; + let now = chrono::Utc::now().timestamp(); + chat_with_anna(&ctx, Some(now - 3600)).await?; + assert_eq!( + receive(&ctx, 7, "r1", story("s1", now, true)).await?, + Delivery::Ack + ); + let database = ctx.app_db.read().await.clone(); + let stories: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE is_story = 1") + .fetch_one(&database.pool) + .await?; + assert_eq!(stories, 1); + assert!(outbox_kinds(&ctx).await?.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn an_expired_story_is_ignored_without_a_receipt() -> Result<()> { + let (_directory, ctx) = context(true).await?; + let late = chrono::Utc::now().timestamp() - STORY_LIFETIME_SECONDS - 60; + assert_eq!( + receive(&ctx, 7, "r1", story("s1", late, true)).await?, + Delivery::Withhold + ); + let database = ctx.app_db.read().await.clone(); + let messages: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages") + .fetch_one(&database.pool) + .await?; + assert_eq!(messages, 0); + assert!(outbox_kinds(&ctx).await?.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn a_story_from_someone_not_accepted_is_dropped() -> Result<()> { + let (_directory, ctx) = context(true).await?; + let now = chrono::Utc::now().timestamp(); + assert_eq!( + receive(&ctx, 8, "r1", story("s1", now, true)).await?, + Delivery::Ack + ); + let database = ctx.app_db.read().await.clone(); + let messages: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages") + .fetch_one(&database.pool) + .await?; + assert_eq!(messages, 0); + assert!(outbox_kinds(&ctx).await?.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn a_story_between_people_who_stopped_writing_is_not_announced() -> Result<()> { + let (_directory, ctx) = context(true).await?; + let now = chrono::Utc::now().timestamp(); + // They last wrote to each other just over two weeks ago. + chat_with_anna( + &ctx, + Some(now - STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS - 60), + ) + .await?; + assert_eq!( + receive(&ctx, 7, "r1", story("s1", now, true)).await?, + Delivery::Ack + ); + assert!(outbox_kinds(&ctx).await?.is_empty()); + + // A text from them counts as writing again, and the next story is + // announced. + let text = EncryptedContent { + group_id: Some(Group::direct_chat_id(42, 7)), + text_message: Some(encrypted_content::TextMessage { + sender_message_id: "t1".into(), + text: "hi".into(), + timestamp: now * 1_000, + ..Default::default() + }), + ..Default::default() + }; + receive(&ctx, 7, "r2", text).await?; + assert_eq!( + receive(&ctx, 7, "r3", story("s2", now, true)).await?, + Delivery::Ack + ); + // Ordered by receipt: the text (r2) came before the story (r3). + assert_eq!( + outbox_kinds(&ctx).await?, + vec!["text".to_owned(), "story".to_owned()] + ); + Ok(()) + } + + #[tokio::test] + async fn a_story_does_not_count_as_writing() -> Result<()> { + let (_directory, ctx) = context(true).await?; + let now = chrono::Utc::now().timestamp(); + let chat = chat_with_anna(&ctx, None).await?; + receive(&ctx, 7, "r1", story("s1", now, true)).await?; + let database = ctx.app_db.read().await.clone(); + let last: Option = + sqlx::query_scalar("SELECT last_text_or_media_at FROM groups WHERE group_id = ?") + .bind(&chat) + .fetch_one(&database.pool) + .await?; + assert_eq!(last, None); + assert!(outbox_kinds(&ctx).await?.is_empty()); + Ok(()) + } + + #[test] + fn a_future_timestamp_is_capped_at_arrival() { + let content = story("s1", 2_000, true); + assert_eq!( + super::posted_at(content.story.as_ref().unwrap(), 1_000), + 1_000 + ); + } +} diff --git a/rust/src/api/messages/incoming/text_message.rs b/rust/src/api/messages/incoming/text_message.rs index c50f91e6..c8be6458 100644 --- a/rust/src/api/messages/incoming/text_message.rs +++ b/rust/src/api/messages/incoming/text_message.rs @@ -37,6 +37,7 @@ pub(crate) async fn handle_text_message( .await?; Group::increase_last_message_exchange(t, group_id, timestamp).await?; + Group::record_text_or_media(t, group_id, timestamp).await?; Ok(()) } @@ -72,6 +73,17 @@ pub(crate) async fn handle_message_update( .remove_files_if_deleted(t, &media.media_id, &media.media_type) .await?; } + + // A story taken down before it was seen should not stay announced. + let cleared_at = crate::utils::current_time().timestamp(); + sqlx::query!( + r#"UPDATE notification_outbox SET cleared_at = ? + WHERE message_id = ? AND kind = 'story' AND cleared_at IS NULL"#, + cleared_at, + update.sender_message_id, + ) + .execute(&mut **t) + .await?; } Type::EditText => { Message::edit_text( diff --git a/rust/src/api/messages/mod.rs b/rust/src/api/messages/mod.rs index 33b64028..3ec1ab3c 100644 --- a/rust/src/api/messages/mod.rs +++ b/rust/src/api/messages/mod.rs @@ -13,6 +13,8 @@ pub(crate) fn content_type_kind( "MessageUpdate" } else if content.media.is_some() { "Media" + } else if content.story.is_some() { + "Story" } else if content.media_update.is_some() { "MediaUpdate" } else if content.contact_update.is_some() { diff --git a/rust/src/api/proto/client/messages.proto b/rust/src/api/proto/client/messages.proto index ce655b5a..2ae09263 100644 --- a/rust/src/api/proto/client/messages.proto +++ b/rust/src/api/proto/client/messages.proto @@ -32,6 +32,7 @@ message EncryptedContent { optional KeyVerificationProof key_verification_proof = 24; optional PasswordLessRecovery passwordless_recovery = 26; optional PasswordLessRecoveryHeartbeat passwordless_recovery_heartbeat = 27; + optional Story story = 30; message ErrorMessages { enum Type { @@ -134,6 +135,17 @@ message EncryptedContent { optional bool widget_only = 12; } + // A story item. It carries no group ID: the receiver files it under its 1:1 + // chat with the sender, keeps it for 24 hours after `media.timestamp`, and + // never shows it in that chat unless it is saved. + message Story { + Media media = 1; + // Whether the sender woke the receiver for this item, which it does for + // the first story in 24 hours only. The receiver announces the story only + // when this is set, so a push and its notification never disagree. + bool notify = 2; + } + message MediaUpdate { enum Type { REOPENED = 0; diff --git a/rust/src/bridge/api.rs b/rust/src/bridge/api.rs index 17d94f88..c0c393cd 100644 --- a/rust/src/bridge/api.rs +++ b/rust/src/bridge/api.rs @@ -14,6 +14,7 @@ use crate::services::contacts::ContactService; use crate::services::media_upload::{MediaSizeReport, MediaUploadService}; use crate::services::messages::MessageService; use crate::services::outbox_dispatch::OutboxDispatchService; +use crate::services::stories::StoryAudience; use crate::user_config::UserConfig; use flutter_rust_bridge::frb; use std::collections::HashMap; @@ -223,16 +224,24 @@ impl RustApi { crate::services::location_metadata::prewarm(ctx) } - /// Creates one outgoing message per selected group and starts the upload. + /// Creates one outgoing message per selected group, plus the story rows + /// for `story_audience`, and starts the upload they all share. pub async fn send_media_to_groups( media_id: String, group_ids: Vec, additional_message_data: Option>, widget_only: bool, + story_audience: Option, ) -> Result<()> { let ctx = Context::get_static()?; MediaUploadService::new(ctx) - .insert_into_messages(media_id, group_ids, additional_message_data, widget_only) + .insert_into_messages( + media_id, + group_ids, + additional_message_data, + widget_only, + story_audience, + ) .await } diff --git a/rust/src/bridge/mod.rs b/rust/src/bridge/mod.rs index 28ad561d..ffde5cec 100644 --- a/rust/src/bridge/mod.rs +++ b/rust/src/bridge/mod.rs @@ -8,6 +8,7 @@ pub mod api; pub mod callbacks; pub mod groups; pub mod logging; +pub mod stories; pub mod user_config; pub mod webxdc; pub mod wrapper; diff --git a/rust/src/bridge/stories.rs b/rust/src/bridge/stories.rs new file mode 100644 index 00000000..bfb74d42 --- /dev/null +++ b/rust/src/bridge/stories.rs @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +//! What the Flutter layer may ask of stories. Story rows themselves are read +//! straight from the database; sending goes through `send_media_to_groups`. + +use crate::context::Context; +use crate::error::Result; +use crate::services::direct_media_upload::MAX_ATTACHMENT_DISPATCHES; +use crate::services::stories; +pub use crate::services::stories::StoryAudience; +use flutter_rust_bridge::frb; + +#[frb(mirror(StoryAudience))] +pub struct _StoryAudience { + pub all: bool, + pub contact_group_ids: Vec, +} + +/// The 1:1 chats a story sent to `audience` would go to right now, one per +/// contact. Together with the chats picked for a direct send, this is who a +/// send reaches. +pub async fn story_audience_chats(audience: StoryAudience) -> Result> { + let ctx = Context::get_static()?; + stories::audience_chats(ctx, &audience).await +} + +/// The most recipients one media send, story rows included, may reach. +#[frb(sync)] +pub fn max_media_recipients() -> u32 { + MAX_ATTACHMENT_DISPATCHES as u32 +} + +/// Takes one of the user's own story items down for every recipient. +pub async fn delete_story_item(media_id: String) -> Result<()> { + let ctx = Context::get_static()?; + stories::delete_item(ctx, &media_id).await +} + +/// Lets go of every story item older than 24 hours. +pub async fn purge_expired_stories() -> Result<()> { + let ctx = Context::get_static()?; + stories::purge_expired(ctx).await +} diff --git a/rust/src/database/app/migrations/0025_stories.sql b/rust/src/database/app/migrations/0025_stories.sql new file mode 100644 index 00000000..38d3355c --- /dev/null +++ b/rust/src/database/app/migrations/0025_stories.sql @@ -0,0 +1,14 @@ +-- A story item is a hidden message row in the 1:1 chat: one per recipient on +-- the sender, all sharing one media file, and one per received item on the +-- receiver. Upload, receipts and opened tracking stay on the message path; +-- chats only show a story row once someone saved it, and unsaved story rows +-- are purged 24 hours after the sender's timestamp instead of by the chat +-- deletion timer. +ALTER TABLE messages ADD COLUMN is_story INTEGER NOT NULL DEFAULT 0 + CHECK (is_story IN (0, 1)); +CREATE INDEX idx_messages_story ON messages(is_story, created_at) + WHERE is_story = 1; + +-- Contact groups whose members receive stories sent to that group. +ALTER TABLE contact_groups ADD COLUMN share_stories INTEGER NOT NULL DEFAULT 0 + CHECK (share_stories IN (0, 1)); diff --git a/rust/src/database/app/migrations/0026_last_text_or_media.sql b/rust/src/database/app/migrations/0026_last_text_or_media.sql new file mode 100644 index 00000000..6cf3c1db --- /dev/null +++ b/rust/src/database/app/migrations/0026_last_text_or_media.sql @@ -0,0 +1,17 @@ +-- When this chat last carried a text or media message, in either direction. +-- Unlike last_message_exchange it ignores reactions, shared data and the +-- chat's creation, and unlike the messages themselves it outlives the chat's +-- deletion timer. A story is announced only between people who wrote to each +-- other within the last two weeks, and this is what that is measured by. +ALTER TABLE groups ADD COLUMN last_text_or_media_at INTEGER; + +-- Existing chats start from what is still known: the newest text or media +-- message left, and the media timestamps the flames keep. +UPDATE groups SET last_text_or_media_at = NULLIF(MAX( + COALESCE((SELECT MAX(created_at) FROM messages + WHERE messages.group_id = groups.group_id + AND messages.type IN ('text', 'media') + AND messages.is_story = 0), 0), + COALESCE(last_message_send, 0), + COALESCE(last_message_received, 0) +), 0); diff --git a/rust/src/database/app/mod.rs b/rust/src/database/app/mod.rs index b211306c..8537438a 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 = 9; +pub const APP_SCHEMA_VERSION: i64 = 11; /// User-owned application tables in the current Rust schema. Rust-only outbox /// tables are deliberately absent because they are reconstructed locally. diff --git a/rust/src/database/app/tables/groups.rs b/rust/src/database/app/tables/groups.rs index 05bc850c..77354ad0 100644 --- a/rust/src/database/app/tables/groups.rs +++ b/rust/src/database/app/tables/groups.rs @@ -440,6 +440,28 @@ impl Group { Ok(is_member) } + /// Notes a text or media message in this chat, sent or received. This is + /// what "these two still talk" is measured by; see + /// `stories::exchanged_recently`. + pub async fn record_text_or_media( + tr: &mut Transaction<'_, Sqlite>, + group_id: &str, + timestamp: i64, + ) -> Result<()> { + let now = current_unix_timestamp()?; + let timestamp = timestamp.min(now.saturating_add(MAX_FUTURE_TIMESTAMP_SKEW_SECONDS)); + sqlx::query!( + r#"UPDATE groups + SET last_text_or_media_at = MAX(COALESCE(last_text_or_media_at, 0), ?) + WHERE group_id = ?"#, + timestamp, + group_id, + ) + .execute(&mut **tr) + .await?; + Ok(()) + } + pub async fn increase_last_message_exchange_to_now( tr: &mut Transaction<'_, Sqlite>, group_id: &str, diff --git a/rust/src/error.rs b/rust/src/error.rs index 6dd55c7e..4da67765 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -78,6 +78,9 @@ pub enum TwonlyError { #[error("encrypted media is {bytes} bytes but the plan allows {limit}")] MediaTooLarge { bytes: i64, limit: i64 }, + #[error("a media send reaches {count} recipients but at most {limit} are allowed")] + TooManyRecipients { count: usize, limit: usize }, + /// The group server refused a state update because it was written against /// a version it no longer holds. Somebody else changed the group first, so /// the update has to be applied again to the version that won. diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index a2053491..1057fb23 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 = -1675944024; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1336092087; // Section: executor @@ -462,6 +462,43 @@ fn wire__crate__bridge__webxdc__delete_instance_impl( }, ) } +fn wire__crate__bridge__stories__delete_story_item_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: "delete_story_item", + 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); + let api_media_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::stories::delete_story_item(api_media_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__groups__fetch_group_state_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -968,6 +1005,36 @@ fn wire__crate__bridge__groups__manage_admin_state_impl( }, ) } +fn wire__crate__bridge__stories__max_media_recipients_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "max_media_recipients", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + 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(); + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::bridge::stories::max_media_recipients())?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__bridge__webxdc__members_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1080,6 +1147,41 @@ fn wire__crate__bridge__webxdc__prepare_bundle_impl( }, ) } +fn wire__crate__bridge__stories__purge_expired_stories_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: "purge_expired_stories", + 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::stories::purge_expired_stories().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__logging__read_last_log_lines_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4198,6 +4300,8 @@ fn wire__crate__bridge__api__rust_api_send_media_to_groups_impl( let api_group_ids = >::sse_decode(&mut deserializer); let api_additional_message_data = >>::sse_decode(&mut deserializer); let api_widget_only = ::sse_decode(&mut deserializer); + let api_story_audience = + >::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( @@ -4207,6 +4311,7 @@ fn wire__crate__bridge__api__rust_api_send_media_to_groups_impl( api_group_ids, api_additional_message_data, api_widget_only, + api_story_audience, ) .await?; Ok(output_ok) @@ -6066,6 +6171,44 @@ fn wire__crate__bridge__webxdc__serve_impl( }, ) } +fn wire__crate__bridge__stories__story_audience_chats_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: "story_audience_chats", + 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); + let api_audience = + ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::stories::story_audience_chats(api_audience).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__groups__update_chat_deletion_time_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -6458,10 +6601,17 @@ fn wire__crate__bridge__logging__write_log_impl( #[allow(clippy::unnecessary_literal_unwrap)] const _: fn() = || { - let FlameState = None::.unwrap(); - let _: i64 = FlameState.counter; - let _: bool = FlameState.is_expiring; - let _: bool = FlameState.is_best_friend; + { + let FlameState = None::.unwrap(); + let _: i64 = FlameState.counter; + let _: bool = FlameState.is_expiring; + let _: bool = FlameState.is_best_friend; + } + { + let StoryAudience = None::.unwrap(); + let _: bool = StoryAudience.all; + let _: Vec = StoryAudience.contact_group_ids; + } }; // Section: related_funcs @@ -7308,6 +7458,19 @@ impl SseDecode for Option { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode( + deserializer, + )); + } else { + return None; + } + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -7567,6 +7730,18 @@ impl SseDecode for crate::database::app::SqlValue { } } +impl SseDecode for crate::bridge::stories::StoryAudience { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_all = ::sse_decode(deserializer); + let mut var_contactGroupIds = >::sse_decode(deserializer); + return crate::bridge::stories::StoryAudience { + all: var_all, + contact_group_ids: var_contactGroupIds, + }; + } +} + impl SseDecode for crate::user_config::ThemeMode { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -7678,6 +7853,7 @@ impl SseDecode for crate::user_config::UserConfig { let mut var_storeLocationInMemories = ::sse_decode(deserializer); let mut var_autoStoreAllSendUnlimitedMediaFiles = ::sse_decode(deserializer); let mut var_typingIndicators = ::sse_decode(deserializer); + let mut var_storyNotifications = ::sse_decode(deserializer); let mut var_showRestoreFlame = ::sse_decode(deserializer); let mut var_myBestFriendGroupId = >::sse_decode(deserializer); let mut var_signalLastSignedPreKeyUpdated = @@ -7747,6 +7923,7 @@ impl SseDecode for crate::user_config::UserConfig { store_location_in_memories: var_storeLocationInMemories, auto_store_all_send_unlimited_media_files: var_autoStoreAllSendUnlimitedMediaFiles, typing_indicators: var_typingIndicators, + story_notifications: var_storyNotifications, show_restore_flame: var_showRestoreFlame, my_best_friend_group_id: var_myBestFriendGroupId, signal_last_signed_pre_key_updated: var_signalLastSignedPreKeyUpdated, @@ -7920,166 +8097,169 @@ fn pde_ffi_dispatcher_primary_impl( 9 => wire__crate__bridge__webxdc__create_instance_impl(port, ptr, rust_vec_len, data_len), 10 => wire__crate__bridge__groups__create_new_group_impl(port, ptr, rust_vec_len, data_len), 11 => wire__crate__bridge__webxdc__delete_instance_impl(port, ptr, rust_vec_len, data_len), -12 => wire__crate__bridge__groups__fetch_group_state_impl(port, ptr, rust_vec_len, data_len), -13 => wire__crate__bridge__groups__fetch_group_states_for_unjoined_groups_impl(port, ptr, rust_vec_len, data_len), -14 => wire__crate__bridge__groups__fetch_missing_group_public_keys_impl(port, ptr, rust_vec_len, data_len), -15 => wire__crate__bridge__groups__flame_state_impl(port, ptr, rust_vec_len, data_len), -16 => wire__crate__bridge__groups__flame_states_impl(port, ptr, rust_vec_len, data_len), -17 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_change_exclusion_for_contact_impl(port, ptr, rust_vec_len, data_len), -18 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_current_version_impl(port, ptr, rust_vec_len, data_len), -19 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_verification_state_for_user_impl(port, ptr, rust_vec_len, data_len), -20 => wire__crate__bridge__callbacks__init_flutter_callbacks_impl(port, ptr, rust_vec_len, data_len), -21 => wire__crate__bridge__initialize_twonly_flutter_impl(port, ptr, rust_vec_len, data_len), -22 => wire__crate__bridge__initialize_twonly_standalone_impl(port, ptr, rust_vec_len, data_len), -23 => wire__crate__bridge__webxdc__instance_impl(port, ptr, rust_vec_len, data_len), -24 => wire__crate__bridge__groups__leave_group_impl(port, ptr, rust_vec_len, data_len), -25 => wire__crate__bridge__logging__load_log_file_impl(port, ptr, rust_vec_len, data_len), -26 => wire__crate__bridge__groups__manage_admin_state_impl(port, ptr, rust_vec_len, data_len), -27 => wire__crate__bridge__webxdc__members_impl(port, ptr, rust_vec_len, data_len), -28 => wire__crate__bridge__webxdc__one_time_instances_impl(port, ptr, rust_vec_len, data_len), -29 => wire__crate__bridge__webxdc__prepare_bundle_impl(port, ptr, rust_vec_len, data_len), -30 => wire__crate__bridge__logging__read_last_log_lines_impl(port, ptr, rust_vec_len, data_len), -31 => wire__crate__bridge__webxdc__refresh_catalog_impl(port, ptr, rust_vec_len, data_len), -32 => wire__crate__bridge__groups__remove_member_from_group_impl(port, ptr, rust_vec_len, data_len), -33 => wire__crate__bridge__groups__restore_flames_impl(port, ptr, rust_vec_len, data_len), -34 => wire__crate__bridge__api__rust_api_abandon_media_impl(port, ptr, rust_vec_len, data_len), -35 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len), -36 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len), -38 => wire__crate__bridge__api__rust_api_authentication_headers_impl(port, ptr, rust_vec_len, data_len), -40 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len), -41 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len), -42 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -43 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len), -44 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len), -45 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len), -46 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len), -47 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len), -48 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len), -49 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len), -50 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len), -52 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len), -53 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len), -54 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len), -55 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), -56 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), -57 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), -58 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), -59 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len), -60 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), -61 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), -62 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len), -63 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), -64 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), -65 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), -66 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), -67 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), -68 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -69 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), -70 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), -71 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), -72 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len), -73 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len), -74 => wire__crate__bridge__api__rust_api_initialize_memory_location_impl(port, ptr, rust_vec_len, data_len), -75 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), -76 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), -77 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), -78 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), -79 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), -80 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), -81 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len), -82 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len), -83 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len), -84 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), -85 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), -86 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len), -87 => wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(port, ptr, rust_vec_len, data_len), -88 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len), -89 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len), -90 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len), -91 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), -92 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len), -93 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -94 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -95 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), -96 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), -97 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len), -98 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), -99 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), -100 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), -101 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), -102 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), -103 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), -104 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len), -105 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len), -106 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len), -107 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), -108 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), -109 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), -110 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), -111 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len), -112 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), -113 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), -114 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), -115 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), -116 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len), -117 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), -118 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len), -119 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len), -120 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len), -121 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), -122 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len), -123 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), -124 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len), -125 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len), -126 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len), -127 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len), -128 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), -129 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), -130 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), -131 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), -132 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), -133 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), -134 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), -135 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), -136 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), -137 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), -138 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), -139 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), -140 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -141 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), -142 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -143 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), -144 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -145 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -146 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -147 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), -148 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), -149 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), -150 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), -151 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len), -152 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), -153 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), -154 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), -155 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), -156 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), -157 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), -158 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), -159 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len), -160 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), -161 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), -162 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len), -163 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), -164 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), -165 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len), -166 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len), -167 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), -168 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), -169 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len), -171 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), -172 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), -173 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), -174 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), -175 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), +12 => wire__crate__bridge__stories__delete_story_item_impl(port, ptr, rust_vec_len, data_len), +13 => wire__crate__bridge__groups__fetch_group_state_impl(port, ptr, rust_vec_len, data_len), +14 => wire__crate__bridge__groups__fetch_group_states_for_unjoined_groups_impl(port, ptr, rust_vec_len, data_len), +15 => wire__crate__bridge__groups__fetch_missing_group_public_keys_impl(port, ptr, rust_vec_len, data_len), +16 => wire__crate__bridge__groups__flame_state_impl(port, ptr, rust_vec_len, data_len), +17 => wire__crate__bridge__groups__flame_states_impl(port, ptr, rust_vec_len, data_len), +18 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_change_exclusion_for_contact_impl(port, ptr, rust_vec_len, data_len), +19 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_current_version_impl(port, ptr, rust_vec_len, data_len), +20 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_verification_state_for_user_impl(port, ptr, rust_vec_len, data_len), +21 => wire__crate__bridge__callbacks__init_flutter_callbacks_impl(port, ptr, rust_vec_len, data_len), +22 => wire__crate__bridge__initialize_twonly_flutter_impl(port, ptr, rust_vec_len, data_len), +23 => wire__crate__bridge__initialize_twonly_standalone_impl(port, ptr, rust_vec_len, data_len), +24 => wire__crate__bridge__webxdc__instance_impl(port, ptr, rust_vec_len, data_len), +25 => wire__crate__bridge__groups__leave_group_impl(port, ptr, rust_vec_len, data_len), +26 => wire__crate__bridge__logging__load_log_file_impl(port, ptr, rust_vec_len, data_len), +27 => wire__crate__bridge__groups__manage_admin_state_impl(port, ptr, rust_vec_len, data_len), +29 => wire__crate__bridge__webxdc__members_impl(port, ptr, rust_vec_len, data_len), +30 => wire__crate__bridge__webxdc__one_time_instances_impl(port, ptr, rust_vec_len, data_len), +31 => wire__crate__bridge__webxdc__prepare_bundle_impl(port, ptr, rust_vec_len, data_len), +32 => wire__crate__bridge__stories__purge_expired_stories_impl(port, ptr, rust_vec_len, data_len), +33 => wire__crate__bridge__logging__read_last_log_lines_impl(port, ptr, rust_vec_len, data_len), +34 => wire__crate__bridge__webxdc__refresh_catalog_impl(port, ptr, rust_vec_len, data_len), +35 => wire__crate__bridge__groups__remove_member_from_group_impl(port, ptr, rust_vec_len, data_len), +36 => wire__crate__bridge__groups__restore_flames_impl(port, ptr, rust_vec_len, data_len), +37 => wire__crate__bridge__api__rust_api_abandon_media_impl(port, ptr, rust_vec_len, data_len), +38 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len), +39 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len), +41 => wire__crate__bridge__api__rust_api_authentication_headers_impl(port, ptr, rust_vec_len, data_len), +43 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len), +44 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len), +45 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), +46 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len), +47 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len), +48 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len), +49 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len), +50 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len), +51 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len), +52 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len), +53 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len), +55 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len), +56 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len), +57 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len), +58 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), +59 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), +60 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), +61 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), +62 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len), +63 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), +64 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), +65 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len), +66 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), +67 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), +68 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), +69 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), +70 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), +71 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +72 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), +73 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), +74 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), +75 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len), +76 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len), +77 => wire__crate__bridge__api__rust_api_initialize_memory_location_impl(port, ptr, rust_vec_len, data_len), +78 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), +79 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), +80 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), +81 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), +82 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), +83 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), +84 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len), +85 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len), +86 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len), +87 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), +88 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), +89 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len), +90 => wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(port, ptr, rust_vec_len, data_len), +91 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len), +92 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len), +93 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len), +94 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), +95 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len), +96 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), +97 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +98 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), +99 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), +100 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len), +101 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), +102 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), +103 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), +104 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), +105 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), +106 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), +107 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len), +108 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len), +109 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len), +110 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), +111 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), +112 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), +113 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), +114 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len), +115 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), +116 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), +117 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), +118 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), +119 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len), +120 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), +121 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len), +122 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len), +123 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len), +124 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), +125 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len), +126 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), +127 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len), +128 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len), +129 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len), +130 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len), +131 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), +132 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), +133 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), +134 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), +135 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), +136 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), +137 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), +138 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), +139 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), +140 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), +141 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), +142 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), +143 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +144 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), +145 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +146 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), +147 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +148 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +149 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +150 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), +151 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), +152 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), +153 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), +154 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len), +155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), +156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), +157 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), +158 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), +159 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), +160 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), +161 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), +162 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len), +163 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), +164 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), +165 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len), +166 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), +167 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), +168 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len), +169 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len), +170 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len), +171 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), +172 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), +173 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len), +175 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), +176 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), +177 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), +178 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), +179 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -8092,17 +8272,18 @@ fn pde_ffi_dispatcher_sync_impl( ) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 37 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len), - 39 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len), - 51 => { + 28 => wire__crate__bridge__stories__max_media_recipients_impl(ptr, rust_vec_len, data_len), + 40 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len), + 42 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len), + 54 => { wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len) } - 170 => wire__crate__bridge__user_config__user_config_api_clone_impl( + 174 => wire__crate__bridge__user_config__user_config_api_clone_impl( ptr, rust_vec_len, data_len, ), - 176 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), + 180 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -8877,6 +9058,27 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for FrbWrapper { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.0.all.into_into_dart().into_dart(), + self.0.contact_group_ids.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for FrbWrapper +{ +} +impl flutter_rust_bridge::IntoIntoDart> + for crate::bridge::stories::StoryAudience +{ + fn into_into_dart(self) -> FrbWrapper { + self.into() + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::user_config::ThemeMode { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { @@ -8965,6 +9167,7 @@ impl flutter_rust_bridge::IntoDart for crate::user_config::UserConfig { .into_into_dart() .into_dart(), self.typing_indicators.into_into_dart().into_dart(), + self.story_notifications.into_into_dart().into_dart(), self.show_restore_flame.into_into_dart().into_dart(), self.my_best_friend_group_id.into_into_dart().into_dart(), self.signal_last_signed_pre_key_updated @@ -9797,6 +10000,16 @@ impl SseEncode for Option { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -10001,6 +10214,14 @@ impl SseEncode for crate::database::app::SqlValue { } } +impl SseEncode for crate::bridge::stories::StoryAudience { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.all, serializer); + >::sse_encode(self.contact_group_ids, serializer); + } +} + impl SseEncode for crate::user_config::ThemeMode { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -10119,6 +10340,7 @@ impl SseEncode for crate::user_config::UserConfig { ::sse_encode(self.store_location_in_memories, serializer); ::sse_encode(self.auto_store_all_send_unlimited_media_files, serializer); ::sse_encode(self.typing_indicators, serializer); + ::sse_encode(self.story_notifications, serializer); ::sse_encode(self.show_restore_flame, serializer); >::sse_encode(self.my_best_friend_group_id, serializer); >>::sse_encode( diff --git a/rust/src/services/background.rs b/rust/src/services/background.rs index 75f6594c..2edb7fd8 100644 --- a/rust/src/services/background.rs +++ b/rust/src/services/background.rs @@ -82,6 +82,10 @@ pub async fn run(config: InitConfig, job: Job) -> Result { MediaUploadService::new(&ctx).start_upload(media_id).await?; } Job::Flush => { + // First, so nothing below spends effort on an expired story. + if let Err(error) = crate::services::stories::purge_expired(&ctx).await { + tracing::warn!(%error, "could not purge expired stories"); + } if let Err(error) = MediaUploadService::new(&ctx).finish_started_uploads().await { tracing::warn!(%error, "could not finish started media uploads"); } diff --git a/rust/src/services/direct_media_upload.rs b/rust/src/services/direct_media_upload.rs index 2e3b6a77..ab21d110 100644 --- a/rust/src/services/direct_media_upload.rs +++ b/rust/src/services/direct_media_upload.rs @@ -9,6 +9,7 @@ use crate::context::Context; use crate::database::app::{tables::MediaFile, AppDatabase}; use crate::error::{Result, TwonlyError}; use crate::native::transfer; +use crate::services::stories::{STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS, STORY_LIFETIME_SECONDS}; use chacha20poly1305::aead::{AeadInPlace, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use prost::Message; @@ -39,6 +40,8 @@ const REFILL_THRESHOLD_KEY: &str = "direct_media_refill_threshold"; /// durable jobs. const API_NAMESPACE_KEY: &str = "direct_media_api_namespace"; const WATCH_FIRST_DELAY: Duration = Duration::from_secs(2); +/// The server accepts at most this many recipients for one uploaded attachment. +pub(crate) const MAX_ATTACHMENT_DISPATCHES: usize = 300; const WATCH_MAX_DELAY: Duration = Duration::from_secs(120); static WATCHING: AtomicBool = AtomicBool::new(false); @@ -159,6 +162,7 @@ struct MessageRow { quotes_message_id: Option, additional_message_data: Option>, is_widget_media: i64, + is_story: i64, } #[derive(FromRow)] @@ -953,14 +957,22 @@ impl DirectMediaUploadService { .execute(&database.pool) .await?; + let now = chrono::Utc::now().timestamp(); let messages = sqlx::query_as::<_, MessageRow>( r#"SELECT group_id, message_id, created_at, quotes_message_id, - additional_message_data, is_widget_media + additional_message_data, is_widget_media, is_story FROM messages WHERE media_id = ?"#, ) .bind(media_id) .fetch_all(&database.pool) - .await?; + .await? + .into_iter() + // An upload that took longer than a story lives would deliver an item + // its recipients ignore on arrival. + .filter(|message| { + message.is_story == 0 || message.created_at + STORY_LIFETIME_SECONDS > now + }) + .collect::>(); if messages.is_empty() { return Err(TwonlyError::Generic( "media has no recipient messages".into(), @@ -988,23 +1000,55 @@ impl DirectMediaUploadService { for recipient in recipients { let mut download_token = vec![0_u8; 32]; rand::rng().fill_bytes(&mut download_token); - let content = EncryptedContent { - group_id: Some(message.group_id.clone()), - media: Some(encrypted_content::Media { - sender_message_id: message.message_id.clone(), - r#type: media_type as i32, - display_limit_in_milliseconds: media.display_limit_in_milliseconds, - requires_authentication: media.requires_authentication != 0, - timestamp: message.created_at.saturating_mul(1_000), - quote_message_id: message.quotes_message_id.clone(), - download_token: Some(download_token.clone()), - encryption_key: Some(key.to_vec()), - encryption_mac: Some(tag.to_vec()), - encryption_nonce: Some(nonce.to_vec()), - additional_message_data: message.additional_message_data.clone(), - widget_only: Some(message.is_widget_media != 0), - }), - ..Default::default() + let mut wake = message.is_widget_media == 0; + let content = if message.is_story != 0 { + wake = story_wakes_recipient( + &database, + &message, + media_id, + recipient.contact_id, + now, + ) + .await?; + EncryptedContent { + story: Some(encrypted_content::Story { + media: Some(encrypted_content::Media { + sender_message_id: message.message_id.clone(), + r#type: media_type as i32, + display_limit_in_milliseconds: None, + requires_authentication: false, + timestamp: message.created_at.saturating_mul(1_000), + quote_message_id: None, + download_token: Some(download_token.clone()), + encryption_key: Some(key.to_vec()), + encryption_mac: Some(tag.to_vec()), + encryption_nonce: Some(nonce.to_vec()), + additional_message_data: message.additional_message_data.clone(), + widget_only: None, + }), + notify: wake, + }), + ..Default::default() + } + } else { + EncryptedContent { + group_id: Some(message.group_id.clone()), + media: Some(encrypted_content::Media { + sender_message_id: message.message_id.clone(), + r#type: media_type as i32, + display_limit_in_milliseconds: media.display_limit_in_milliseconds, + requires_authentication: media.requires_authentication != 0, + timestamp: message.created_at.saturating_mul(1_000), + quote_message_id: message.quotes_message_id.clone(), + download_token: Some(download_token.clone()), + encryption_key: Some(key.to_vec()), + encryption_mac: Some(tag.to_vec()), + encryption_nonce: Some(nonce.to_vec()), + additional_message_data: message.additional_message_data.clone(), + widget_only: Some(message.is_widget_media != 0), + }), + ..Default::default() + } }; let encrypted_body = send_c2c_message_to_contact() .ctx(&self.ctx) @@ -1023,13 +1067,14 @@ impl DirectMediaUploadService { encrypted_body, // Widget media arrives already opened and raises no // notification, so a push for it only buys the recipient - // an alert with nothing behind it. - push_data: (message.is_widget_media == 0).then(|| vec![1]), + // an alert with nothing behind it. A story wakes only for + // the first item in a day, see `story_wakes_recipient`. + push_data: wake.then(|| vec![1]), download_token, }); } } - if dispatches.is_empty() || dispatches.len() > 300 { + if dispatches.is_empty() || dispatches.len() > MAX_ATTACHMENT_DISPATCHES { return Err(TwonlyError::Generic(format!( "direct-media fan-out has {} dispatches", dispatches.len() @@ -1046,6 +1091,64 @@ impl DirectMediaUploadService { } } +/// Whether a story row's recipient is woken for it. Stories wake only for the +/// first item in a day, so a burst of them is one alert rather than a stream: +/// +/// - an older own story row in the same chat that has not expired yet means +/// this is not the first. Ties on the timestamp are broken by message id, so +/// two items prepared at once cannot each defer to the other. +/// - a direct copy of the same media already wakes the recipient. +/// - a row the server or recipient already acknowledged is being prepared +/// again for a reupload, and was announced the first time. +/// - the two have not sent each other a text or media message in two weeks; +/// the recipient would not announce it then, so waking them is pointless. +/// +/// The answer also travels inside the story, so the receiver announces +/// exactly the items it was woken for. +async fn story_wakes_recipient( + database: &AppDatabase, + message: &MessageRow, + media_id: &str, + contact_id: i64, + now: i64, +) -> Result { + let quiet = sqlx::query_scalar::<_, i64>( + r#"SELECT EXISTS( + SELECT 1 FROM messages older + WHERE older.is_story = 1 AND older.sender_id IS NULL + AND older.group_id = ? AND older.media_id IS NOT NULL + AND older.created_at > ? + AND (older.created_at < ? + OR (older.created_at = ? AND older.message_id < ?)) + ) OR EXISTS( + SELECT 1 FROM messages direct + JOIN group_members gm ON gm.group_id = direct.group_id + WHERE direct.media_id = ? AND direct.is_story = 0 AND gm.contact_id = ? + ) OR EXISTS( + SELECT 1 FROM message_actions action + WHERE action.message_id = ? AND action.contact_id = ? + AND action.type IN ('ackByServerAt', 'ackByUserAt') + ) OR NOT EXISTS( + SELECT 1 FROM groups chat + WHERE chat.group_id = ? AND chat.last_text_or_media_at > ? + )"#, + ) + .bind(&message.group_id) + .bind(now - STORY_LIFETIME_SECONDS) + .bind(message.created_at) + .bind(message.created_at) + .bind(&message.message_id) + .bind(media_id) + .bind(contact_id) + .bind(&message.message_id) + .bind(contact_id) + .bind(&message.group_id) + .bind(now - STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS) + .fetch_one(&database.pool) + .await?; + Ok(quiet == 0) +} + fn media_extension(media_type: &str) -> &'static str { match media_type { "video" => "mp4", @@ -1090,6 +1193,141 @@ fn write_multipart_body( #[cfg(test)] mod tests { use super::*; + use crate::services::media_upload::MediaUploadService; + use crate::user_config::UserConfig; + use libsignal_protocol::IdentityKeyPair; + use rand::SeedableRng; + + async fn upload_test_context(root: &Path, user_id: i64) -> anyhow::Result> { + let data_dir = root.join("data"); + std::fs::create_dir_all(data_dir.join("keyvalue"))?; + let config = UserConfig { + user_id, + username: format!("user-{user_id}"), + display_name: format!("User {user_id}"), + is_user_discovery_enabled: false, + ..Default::default() + }; + std::fs::write( + data_dir.join("keyvalue/user.json"), + serde_json::to_vec(&config)?, + )?; + let context = Context::init_for_testing(root.join("database"), data_dir).await?; + let mut csprng = rand::rngs::StdRng::from_os_rng(); + let identity = IdentityKeyPair::generate(&mut csprng); + let registration_id = rand::Rng::random::(&mut csprng) & 0x7fff_ffff; + context + .inject_test_signal_identity(identity.serialize().to_vec(), registration_id as i64) + .await?; + context.inject_test_user_id(user_id).await?; + Ok(context) + } + + #[tokio::test] + async fn story_upload_preparation_encrypts_media_and_dispatch_payload() -> anyhow::Result<()> { + let sender_dir = tempfile::tempdir()?; + let receiver_dir = tempfile::tempdir()?; + let sender = upload_test_context(sender_dir.path(), 42).await?; + let receiver = upload_test_context(receiver_dir.path(), 7).await?; + + let receiver_bundle = receiver + .signal_engine + .lock() + .await + .as_ref() + .unwrap() + .generate_bundle() + .await?; + sender + .signal_engine + .lock() + .await + .as_ref() + .unwrap() + .process_prekey_bundle("7".into(), 1, receiver_bundle) + .await?; + + let upload = MediaUploadService::new(&sender); + let media_id = upload.initialize("image".into(), None, false).await?; + let original = b"story image bytes"; + let source = crate::services::mediafiles::MediaFileService::new(&sender) + .temp_path(&media_id, "image"); + if let Some(parent) = source.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&source, original)?; + + let database = sender.app_db.read().await.clone(); + sqlx::query( + "INSERT INTO contacts(user_id, username, accepted, signal_version) VALUES(7, 'receiver', 1, 'v2')", + ) + .execute(&database.pool) + .await?; + // They wrote to each other recently, so the story wakes the receiver. + sqlx::query( + "INSERT INTO groups(group_id, group_name, is_direct_chat, last_text_or_media_at) VALUES('direct-7', 'Receiver', 1, CAST(strftime('%s','now') AS INTEGER))", + ) + .execute(&database.pool) + .await?; + sqlx::query("INSERT INTO group_members(group_id, contact_id) VALUES('direct-7', 7)") + .execute(&database.pool) + .await?; + let created_at = chrono::Utc::now().timestamp(); + sqlx::query( + r#"INSERT INTO messages + (message_id, group_id, type, media_id, is_story, created_at) + VALUES('story-message', 'direct-7', 'media', ?, 1, ?)"#, + ) + .bind(&media_id) + .bind(created_at) + .execute(&database.pool) + .await?; + drop(database); + + let (encrypted_path, encrypted_size, manifest) = DirectMediaUploadService::new(&sender) + .prepare_media(&media_id) + .await?; + + let encrypted_media = std::fs::read(encrypted_path)?; + assert_eq!(encrypted_size, original.len() as i64); + assert_ne!(encrypted_media, original); + assert_eq!(manifest.dispatches.len(), 1); + let dispatch = &manifest.dispatches[0]; + assert_eq!(dispatch.recipient_user_id, 7); + assert_eq!(dispatch.push_data.as_deref(), Some([1].as_slice())); + + let envelope = + crate::api::proto::client::Message::decode(dispatch.encrypted_body.as_slice())?; + assert_eq!( + envelope.r#type, + crate::api::proto::client::message::Type::CiphertextV2 as i32 + ); + let plaintext = receiver + .signal_engine + .lock() + .await + .as_ref() + .unwrap() + .decrypt_message( + "42".into(), + 1, + envelope + .encrypted_content + .expect("encrypted story envelope"), + ) + .await?; + let content = EncryptedContent::decode(plaintext.as_slice())?; + let story = content.story.expect("story payload"); + assert!(story.notify); + let media = story.media.expect("story media payload"); + assert_eq!(media.sender_message_id, "story-message"); + assert_eq!(media.timestamp, created_at * 1_000); + assert_eq!(media.download_token, Some(dispatch.download_token.clone())); + assert_eq!(media.encryption_key.as_deref().map(<[u8]>::len), Some(32)); + assert_eq!(media.encryption_nonce.as_deref().map(<[u8]>::len), Some(12)); + assert_eq!(media.encryption_mac.as_deref().map(<[u8]>::len), Some(16)); + Ok(()) + } #[test] fn cancelled_watcher_releases_process_claim() { @@ -1399,4 +1637,102 @@ mod tests { // out makes the server abandon the transfer as `not_in_client_cache`. assert_eq!(known, vec!["slot-cached", "slot-reserved"]); } + + fn story_row(message_id: &str, created_at: i64) -> MessageRow { + MessageRow { + group_id: "d".into(), + message_id: message_id.into(), + created_at, + quotes_message_id: None, + additional_message_data: None, + is_widget_media: 0, + is_story: 1, + } + } + + #[tokio::test] + async fn only_the_first_story_of_a_day_wakes_its_recipient() { + let directory = tempfile::tempdir().unwrap(); + let database_path = directory.path().join("app.sqlite"); + let database = AppDatabase::new(database_path.to_str().unwrap(), None, false) + .await + .unwrap(); + database.run_migrations().await.unwrap(); + let now = 1_000_000_000; + let expired = now - STORY_LIFETIME_SECONDS - 60; + for statement in [ + "INSERT INTO contacts(user_id, username, accepted) VALUES (7, 'anna', 1)".to_owned(), + format!("INSERT INTO groups(group_id, group_name, is_direct_chat, last_text_or_media_at) VALUES ('d', 'Anna', 1, {}), ('trip', 'Trip', 0, NULL)", now - 3600), + "INSERT INTO group_members(group_id, contact_id) VALUES ('d', 7), ('trip', 7)".to_owned(), + "INSERT INTO media_files(media_id, type) VALUES ('old', 'image'), ('first', 'image'), ('second', 'image'), ('twin', 'image')".to_owned(), + format!("INSERT INTO messages(message_id, group_id, type, media_id, is_story, created_at) VALUES ('old', 'd', 'media', 'old', 1, {expired})"), + format!("INSERT INTO messages(message_id, group_id, type, media_id, is_story, created_at) VALUES ('first', 'd', 'media', 'first', 1, {})", now - 120), + format!("INSERT INTO messages(message_id, group_id, type, media_id, is_story, created_at) VALUES ('second', 'd', 'media', 'second', 1, {now})"), + format!("INSERT INTO messages(message_id, group_id, type, media_id, is_story, created_at) VALUES ('twin', 'd', 'media', 'twin', 1, {now})"), + ] { + sqlx::query(sqlx::AssertSqlSafe(statement)) + .execute(&database.pool) + .await + .unwrap(); + } + let wakes = |row: MessageRow, media_id: &'static str| { + let database = &database; + async move { + story_wakes_recipient(database, &row, media_id, 7, now) + .await + .unwrap() + } + }; + + // An expired story does not count against the first one of a new day. + assert!(wakes(story_row("first", now - 120), "first").await); + assert!(!wakes(story_row("second", now), "second").await); + // Two items posted in the same second: exactly one of them is first + // once the older one is gone. + sqlx::query("DELETE FROM messages WHERE message_id = 'first'") + .execute(&database.pool) + .await + .unwrap(); + assert!(wakes(story_row("second", now), "second").await); + assert!(!wakes(story_row("twin", now), "twin").await); + + // A direct copy of the same media already wakes the recipient. + sqlx::query( + "INSERT INTO messages(message_id, group_id, type, media_id) VALUES ('direct', 'trip', 'media', 'second')", + ) + .execute(&database.pool) + .await + .unwrap(); + assert!(!wakes(story_row("second", now), "second").await); + sqlx::query("DELETE FROM messages WHERE message_id = 'direct'") + .execute(&database.pool) + .await + .unwrap(); + + // Preparing an item again after it reached the server does not + // announce it a second time. + sqlx::query( + "INSERT INTO message_actions(message_id, contact_id, type) VALUES ('second', 7, 'ackByServerAt')", + ) + .execute(&database.pool) + .await + .unwrap(); + assert!(!wakes(story_row("second", now), "second").await); + + // Two weeks without a text or media message between them: the + // recipient would not announce it, so they are not woken for it. + sqlx::query("DELETE FROM message_actions") + .execute(&database.pool) + .await + .unwrap(); + assert!(wakes(story_row("second", now), "second").await); + sqlx::query(sqlx::AssertSqlSafe(format!( + "UPDATE groups SET last_text_or_media_at = {} WHERE group_id = 'd'", + now - STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS - 60 + ))) + .execute(&database.pool) + .await + .unwrap(); + assert!(!wakes(story_row("second", now), "second").await); + } } diff --git a/rust/src/services/media_upload.rs b/rust/src/services/media_upload.rs index a8f67b0e..4b88fdd8 100644 --- a/rust/src/services/media_upload.rs +++ b/rust/src/services/media_upload.rs @@ -17,11 +17,12 @@ use crate::error::{Result, TwonlyError}; use crate::native::gallery; use crate::native::prepare; use crate::native::video; -use crate::services::direct_media_upload::DirectMediaUploadService; +use crate::services::direct_media_upload::{DirectMediaUploadService, MAX_ATTACHMENT_DISPATCHES}; use crate::services::media_codec; use crate::services::media_exif; use crate::services::mediafiles::MediaFileService; use crate::services::messages::MessageService; +use crate::services::stories::{self, StoryAudience}; use crate::user_config::UserConfig; use crate::utils::new_uuid_v7; use prost::Message as _; @@ -161,19 +162,68 @@ impl MediaUploadService { Ok(media_id) } - /// Turns an edited media file into one outgoing message per selected group - /// and hands the send to the background uploader. + /// Turns an edited media file into one outgoing message per selected group, + /// plus one hidden story row per contact `story` reaches, and hands the + /// send to the background uploader. Everything shares one upload. pub async fn insert_into_messages( &self, media_id: String, group_ids: Vec, additional_message_data: Option>, widget_only: bool, + story: Option, ) -> Result<()> { let database = self.ctx.app_db.read().await.clone(); let now = chrono::Utc::now().timestamp(); let mut transaction = database.pool.begin().await?; + let story_recipients = match &story { + Some(audience) => { + let (media_type, requires_authentication) = sqlx::query_as::<_, (String, i64)>( + "SELECT type, requires_authentication FROM media_files WHERE media_id = ?", + ) + .bind(&media_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| TwonlyError::Generic(format!("media {media_id} does not exist")))?; + // A story replays for everybody until it expires, which is the + // opposite of what a twonly-protected send promises. + if widget_only + || requires_authentication != 0 + || !matches!(media_type.as_str(), "image" | "video") + { + return Err(TwonlyError::Generic( + "stories take an unprotected image or video".into(), + )); + } + stories::resolve_audience(&mut transaction, audience).await? + } + None => Vec::new(), + }; + // The server takes a bounded number of recipients per upload. Refusing + // here keeps the media from being retried against that limit forever. + let mut dispatches = story_recipients.len(); + for group_id in &group_ids { + dispatches += sqlx::query_scalar::<_, i64>( + r#"SELECT COUNT(*) FROM group_members gm + JOIN contacts c ON c.user_id = gm.contact_id + WHERE gm.group_id = ? AND c.account_deleted = 0 + AND (gm.member_state IS NULL OR gm.member_state != 'leftGroup')"#, + ) + .bind(group_id) + .fetch_one(&mut *transaction) + .await? as usize; + } + if dispatches == 0 { + return Err(TwonlyError::Generic("media send has no recipients".into())); + } + if dispatches > MAX_ATTACHMENT_DISPATCHES { + return Err(TwonlyError::TooManyRecipients { + count: dispatches, + limit: MAX_ATTACHMENT_DISPATCHES, + }); + } + if widget_only { let media = sqlx::query_as::<_, (String, Option)>( "SELECT type, display_limit_in_milliseconds FROM media_files WHERE media_id = ?", @@ -253,8 +303,27 @@ impl MediaUploadService { .execute(&mut *transaction) .await?; Group::record_media_exchange(&mut transaction, group_id, false, now).await?; + Group::record_text_or_media(&mut transaction, group_id, now).await?; } } + // A story row neither lifts its chat nor feeds its flames: it is not + // part of the conversation until somebody saves it. + for contact_id in story_recipients { + let group_id = stories::direct_chat(&self.ctx, &mut transaction, contact_id).await?; + sqlx::query( + r#"INSERT INTO messages + (group_id, message_id, type, media_id, additional_message_data, + is_story, created_at) + VALUES (?, ?, 'media', ?, ?, 1, ?)"#, + ) + .bind(&group_id) + .bind(new_uuid_v7()) + .bind(&media_id) + .bind(additional_message_data.as_deref()) + .bind(now) + .execute(&mut *transaction) + .await?; + } transaction.commit().await?; drop(database); @@ -399,6 +468,9 @@ impl MediaUploadService { if let Err(error) = self.record_plaintext_size(media_id, &temp_path).await { tracing::warn!(media_id, %error, "could not record the media size"); } + if let Err(error) = self.create_story_thumbnail(media_id).await { + tracing::warn!(media_id, %error, "could not create the story thumbnail"); + } // Auto-storing has to happen before the plaintext is consumed, and only // for media the recipient could have kept anyway. @@ -613,6 +685,20 @@ impl MediaUploadService { } }; + // The recipient ignores a story once it expired, so resending one only + // costs traffic until the story purge takes the receipt away. + let expired_story = sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM messages WHERE message_id = ? AND is_story = 1 AND created_at <= ?)", + ) + .bind(&message_id) + .bind(now - stories::STORY_LIFETIME_SECONDS) + .fetch_one(&database.pool) + .await? + != 0; + if expired_story { + return Ok(()); + } + // Nothing can be delivered to a contact who has not accepted yet. if receipt.mark_for_retry_after_accepted.is_some() { let accepted = sqlx::query_scalar::<_, Option>( @@ -972,11 +1058,15 @@ impl MediaUploadService { -- still reopen their own copy. AND (m.sender_id IS NULL OR g.is_direct_chat = 0) ) + -- A story replays until it expires, and a viewer saving it + -- makes the sender store its own copy from these bytes. + OR (m.is_story = 1 AND m.created_at > ?) )"#, ) .bind(media_id) .bind(now - 3 * 60) .bind(now - 2 * 24 * 60 * 60) + .bind(now - stories::STORY_LIFETIME_SECONDS) .fetch_one(&database.pool) .await?; Ok(keepers == 0) @@ -1281,28 +1371,48 @@ impl MediaUploadService { async fn create_thumbnail(&self, media: &MediaRow) -> Result<()> { let files = MediaFileService::new(&self.ctx); let stored = files.stored_path(&media.media_id, &media.media_type); - let thumbnail = files.thumbnail_path(&media.media_id); if media.media_type == "audio" || !stored.exists() { return Ok(()); } - let media_id = media.media_id.clone(); - let is_video = media.media_type == "video"; - if let Err(error) = blocking(move || { - if is_video { - // Only the frame grab needs the platform's video decoder; the - // scaling and encoding is the same code stills use. - let frame = thumbnail.with_extension("frame.png"); - video::extract_frame(&stored, &frame)?; - let result = media_codec::create_image_thumbnail(&frame, &thumbnail); - let _ = std::fs::remove_file(&frame); - result - } else { - media_codec::create_image_thumbnail(&stored, &thumbnail) - } - }) - .await - { - tracing::warn!(media_id, %error, "could not create a thumbnail"); + render_thumbnail( + &media.media_id, + stored, + files.thumbnail_path(&media.media_id), + media.media_type == "video", + ) + .await; + Ok(()) + } + + /// Renders the frame a reply to a video story is shown with. Stored media + /// gets its thumbnail from the stored file, but a story is replied to long + /// before anybody stores it, so this one comes from the plaintext. + pub(crate) async fn create_story_thumbnail(&self, media_id: &str) -> Result<()> { + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + if media.media_type != "video" { + return Ok(()); + } + let database = self.ctx.app_db.read().await.clone(); + let is_story = sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM messages WHERE media_id = ? AND is_story = 1)", + ) + .bind(media_id) + .fetch_one(&database.pool) + .await? + != 0; + let files = MediaFileService::new(&self.ctx); + let source = files.temp_path(media_id, &media.media_type); + let thumbnail = files.thumbnail_path(media_id); + if !is_story || !source.exists() || thumbnail.exists() { + return Ok(()); + } + if render_thumbnail(media_id, source, thumbnail, true).await { + sqlx::query("UPDATE media_files SET has_thumbnail = 1 WHERE media_id = ?") + .bind(media_id) + .execute(&database.pool) + .await?; } Ok(()) } @@ -1501,6 +1611,34 @@ where .map_err(|error| TwonlyError::Generic(error.to_string()))? } +/// Writes a webp thumbnail of `source` to `thumbnail`. A failure only costs +/// the preview, so it is logged and reported rather than returned. +async fn render_thumbnail( + media_id: &str, + source: std::path::PathBuf, + thumbnail: std::path::PathBuf, + is_video: bool, +) -> bool { + let result = blocking(move || { + if is_video { + // Only the frame grab needs the platform's video decoder; the + // scaling and encoding is the same code stills use. + let frame = thumbnail.with_extension("frame.png"); + video::extract_frame(&source, &frame)?; + let result = media_codec::create_image_thumbnail(&frame, &thumbnail); + let _ = std::fs::remove_file(&frame); + result + } else { + media_codec::create_image_thumbnail(&source, &thumbnail) + } + }) + .await; + if let Err(error) = &result { + tracing::warn!(media_id, %error, "could not create a thumbnail"); + } + result.is_ok() +} + fn remove_file(path: &Path) { match std::fs::remove_file(path) { Ok(()) => {} @@ -1531,6 +1669,42 @@ fn parse_reupload_requested_by(value: Option<&str>) -> Vec { mod tests { use super::*; + #[tokio::test] + async fn refuses_a_story_send_with_no_resolved_recipients() -> anyhow::Result<()> { + let temp = tempfile::tempdir()?; + let data_dir = temp.path().join("data"); + std::fs::create_dir_all(data_dir.join("keyvalue"))?; + std::fs::write( + data_dir.join("keyvalue/user.json"), + serde_json::to_vec(&UserConfig::default())?, + )?; + let ctx = Context::init_for_testing(temp.path().join("database"), data_dir).await?; + let service = MediaUploadService::new(&ctx); + let media_id = service.initialize("image".into(), None, false).await?; + + let error = service + .insert_into_messages( + media_id, + vec![], + None, + false, + Some(StoryAudience { + all: true, + contact_group_ids: vec![], + }), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("no recipients")); + let database = ctx.app_db.read().await.clone(); + let messages = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM messages") + .fetch_one(&database.pool) + .await?; + assert_eq!(messages, 0); + Ok(()) + } + #[test] fn parses_both_reupload_requested_by_encodings() { assert!(parse_reupload_requested_by(None).is_empty()); diff --git a/rust/src/services/mediafiles.rs b/rust/src/services/mediafiles.rs index e0a0fdc2..2f6dec1c 100644 --- a/rust/src/services/mediafiles.rs +++ b/rust/src/services/mediafiles.rs @@ -9,6 +9,7 @@ use crate::api::runtime::API_EVENTS; use crate::bridge::api::{ApiEvent, ApiEventKind, RustApi}; use crate::context::Context; use crate::error::{Result, TwonlyError}; +use crate::services::media_upload::MediaUploadService; use chacha20poly1305::aead::{AeadInPlace, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Nonce, Tag}; use prost::Message as _; @@ -238,6 +239,12 @@ impl MediaFileService { return Ok(()); } } + if let Err(error) = MediaUploadService::new(&self.ctx) + .create_story_thumbnail(media_id) + .await + { + tracing::warn!(media_id, %error, "could not create the story thumbnail"); + } let is_widget = sqlx::query_scalar::<_, i64>( "SELECT is_widget_media FROM media_files WHERE media_id = ?", ) @@ -365,12 +372,13 @@ impl MediaFileService { // Widget media is marked opened the moment it arrives, since nobody // ever opens it in a chat. Asking only for what has not been opened // would therefore never ask for it at all, and the state set below - // hides the message for good. + // hides the message for good. A story replays after it was opened, so + // the same holds for it. let targets = sqlx::query_as!( ReuploadTarget, r#"SELECT message_id, sender_id AS "sender_id!: i64" FROM messages WHERE media_id = ? AND sender_id IS NOT NULL - AND (opened_at IS NULL OR is_widget_media = 1)"#, + AND (opened_at IS NULL OR is_widget_media = 1 OR is_story = 1)"#, media_id, ) .fetch_all(&database.pool) diff --git a/rust/src/services/messages.rs b/rust/src/services/messages.rs index 9b3ca33f..7d21ee89 100644 --- a/rust/src/services/messages.rs +++ b/rust/src/services/messages.rs @@ -264,6 +264,7 @@ impl MessageService { ) .execute(&mut *t) .await?; + Group::record_text_or_media(&mut t, &group_id, timestamp / 1000).await?; // The commit hook installed on the connection publishes `groups` and // `messages` on its own, so the chat list is already on its way. t.commit().await?; diff --git a/rust/src/services/mod.rs b/rust/src/services/mod.rs index b98dac3c..4f3d9a74 100644 --- a/rust/src/services/mod.rs +++ b/rust/src/services/mod.rs @@ -17,4 +17,5 @@ pub mod mediafiles; pub mod messages; pub mod notifications; pub mod outbox_dispatch; +pub mod stories; pub mod webxdc; diff --git a/rust/src/services/notifications.rs b/rust/src/services/notifications.rs index c032c484..9ccbe1f5 100644 --- a/rust/src/services/notifications.rs +++ b/rust/src/services/notifications.rs @@ -77,6 +77,63 @@ async fn message_sender( .await?) } +/// Whether `message_id` is one of this device's own story rows, which is what +/// a reply, reaction or save aimed at the user's story points at. +async fn is_own_story(transaction: &mut Transaction<'_, Sqlite>, message_id: &str) -> Result { + Ok(sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM messages + WHERE message_id = ? AND sender_id IS NULL AND is_story = 1 + ) AS "own_story!: bool""#, + message_id + ) + .fetch_one(&mut **transaction) + .await?) +} + +/// Whether `text` is nothing but emoji. A reaction to a story travels as a +/// text quoting it, so this is what tells a reaction from a reply. +fn is_emoji_only(text: &str) -> bool { + fn pictographic(c: char) -> bool { + matches!( + c as u32, + 0x1F000..=0x1FAFF + | 0x2600..=0x27BF + | 0x2300..=0x23FF + | 0x2B00..=0x2BFF + | 0x2190..=0x21FF + | 0x25A0..=0x25FF + | 0x00A9 + | 0x00AE + | 0x203C + | 0x2049 + | 0x2122 + | 0x2139 + | 0x3030 + | 0x303D + | 0x3297 + | 0x3299 + ) + } + // Joiners, variation selectors, skin tones, tags and keycap bases only + // ever build an emoji out of the pictographs around them. + fn component(c: char) -> bool { + matches!( + c as u32, + 0x200D | 0xFE0E | 0xFE0F | 0x20E3 | 0x1F3FB..=0x1F3FF | 0xE0020..=0xE007F + ) || c.is_ascii_digit() + || c == '#' + || c == '*' + } + let text = text.trim(); + !text.is_empty() + && text.chars().count() <= 16 + // A keycap is a digit or sign wrapped by U+20E3; that mark is what + // makes "1\u{fe0f}\u{20e3}" an emoji rather than a number. + && text.chars().any(|c| pictographic(c) || c == '\u{20E3}') + && text.chars().all(|c| pictographic(c) || component(c)) +} + /// Whether this envelope is worth spending the recipient's push budget on. /// /// A wake-up is not free to get wrong. iOS renders the alert the push carried @@ -119,6 +176,9 @@ pub(crate) async fn should_wake_receiver( .is_ok_and(|kind| kind != encrypted_content::media::Type::Reupload) }) || reaction_reaches_author + // The sender decided, and wrote into the story, that this is the first + // item in a day; `record_incoming_event` announces exactly those. + || content.story.as_ref().is_some_and(|story| story.notify) || content .media_update .as_ref() @@ -250,18 +310,28 @@ pub(crate) async fn record_incoming_event( let conversation_id = content.group_id.clone(); let now = current_time().timestamp(); let draft = if let Some(message) = content.text_message.as_ref() { + // An answer to the user's own story says so: an emoji is a reaction + // to it, anything else a reply. + let about_story = match message.quote_message_id.as_deref() { + Some(quoted) => is_own_story(transaction, quoted).await?, + None => false, + }; + let (kind, reaction) = match (about_story, message.quote_message_id.is_some()) { + (true, _) if is_emoji_only(&message.text) => { + ("story_reaction", Some(message.text.trim().to_owned())) + } + (true, _) => ("story_reply", None), + (false, true) => ("response", None), + (false, false) => ("text", None), + }; Some(NotificationDraft { event_id: receipt_id.to_owned(), notification_id: message.sender_message_id.clone(), conversation_id, sender_id: from_user_id, message_id: Some(message.sender_message_id.clone()), - kind: if message.quote_message_id.is_some() { - "response" - } else { - "text" - }, - content: None, + kind, + content: reaction, created_at: milliseconds_to_seconds(message.timestamp), }) } else if let Some(media) = content.media.as_ref() { @@ -288,6 +358,35 @@ pub(crate) async fn record_incoming_event( created_at: milliseconds_to_seconds(media.timestamp), }) } + } else if let Some(story) = content.story.as_ref() { + // Only the first story in a day woke this device, and the sender says + // which one that was. Whether to announce it at all is this device's + // call alone: muted, or from someone the user has not written with in + // two weeks, it stays quiet. A wake-up it declines is silenced by the + // notification extension instead of showing the push's placeholder. + let config = UserConfig::load_required_from(ctx)?; + // The same chat `handle_story` filed the story under. + let chat = + crate::database::app::tables::Group::direct_chat_id(ctx.user_id().await?, from_user_id); + match story.media.as_ref() { + Some(media) if story.notify && config.story_notifications => { + let filed = message_sender(transaction, &media.sender_message_id).await? + == Some(Some(from_user_id)); + let recent = + crate::services::stories::exchanged_recently(transaction, &chat, now).await?; + (filed && recent).then(|| NotificationDraft { + event_id: receipt_id.to_owned(), + notification_id: media.sender_message_id.clone(), + conversation_id: Some(chat.clone()), + sender_id: from_user_id, + message_id: Some(media.sender_message_id.clone()), + kind: "story", + content: None, + created_at: milliseconds_to_seconds(media.timestamp).min(now), + }) + } + _ => None, + } } else if let Some(message) = content.additional_data_message.as_ref() { // A hidden message is state a feature exchanges and leaves no row of // its own, so the only thing in one worth an alert is an app's @@ -331,6 +430,11 @@ pub(crate) async fn record_incoming_event( // else in the group watched it land on somebody else's message and has // nothing to be told, and a reaction the sender put on their own // message concerns nobody here at all. + let kind = if is_own_story(transaction, &reaction.target_message_id).await? { + "story_reaction" + } else { + "reaction" + }; matches!( message_sender(transaction, &reaction.target_message_id).await?, Some(None) @@ -341,13 +445,15 @@ pub(crate) async fn record_incoming_event( conversation_id, sender_id: from_user_id, message_id: Some(reaction.target_message_id.clone()), - kind: "reaction", + kind, content: Some(reaction.emoji.clone()), created_at: now, }) } else if let Some(update) = content.media_update.as_ref() { let update_type = encrypted_content::media_update::Type::try_from(update.r#type)?; + let story = is_own_story(transaction, &update.target_message_id).await?; let kind = match update_type { + encrypted_content::media_update::Type::Stored if story => Some("stored_story"), encrypted_content::media_update::Type::Stored => Some("stored_media"), encrypted_content::media_update::Type::Reopened => Some("reopened_media"), encrypted_content::media_update::Type::DecryptionError => None, @@ -410,7 +516,7 @@ async fn clear_stale_opened(database: &Arc) -> Result<()> { UPDATE notification_outbox SET cleared_at = ? WHERE cleared_at IS NULL - AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc') + AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc', 'story', 'story_reply', 'story_reaction') AND EXISTS ( SELECT 1 FROM messages @@ -616,6 +722,12 @@ pub async fn finalize_wakeup(deadline_ms: u64) -> Result<()> { Ok(Ok(())) => {} } + match tokio::time::timeout(remaining(), crate::services::stories::purge_expired(&ctx)).await { + Ok(Err(error)) => tracing::warn!(%error, "story purge failed during wake-up"), + Err(error) => tracing::info!(%error, "story purge window elapsed"), + Ok(Ok(())) => {} + } + if owns_connection && ctx.is_notification_runtime() { // Bounded on its own rather than out of what is left: closing costs a // round trip at most, and a server that never answers the handshake @@ -726,7 +838,7 @@ pub(crate) async fn clear_opened_messages( SET cleared_at = ? WHERE message_id = ? AND cleared_at IS NULL - AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc') + AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc', 'story', 'story_reply', 'story_reaction') "#, ) .bind(cleared_at) @@ -821,6 +933,10 @@ fn localized_body(locale: &str, row: &PendingRow) -> String { "contact_request" => "notificationContactRequest", "accept_request" => "notificationAcceptRequest", "stored_media" => "notificationStoredMediaFile", + "story" => "notificationStory", + "story_reply" => "notificationStoryReply", + "story_reaction" => "notificationStoryReaction", + "stored_story" => "notificationStoredStory", "reopened_media" => "notificationReopenedMedia", "reaction" => match ( row.target_message_type.as_deref(), @@ -979,6 +1095,108 @@ mod tests { assert_eq!(localized_body("fr-FR", &row), "wants to connect with you."); } + #[test] + fn tells_a_reaction_from_a_reply() { + for reaction in ["🔥", " ❤️ ", "👍🏽", "👨‍👩‍👧", "1️⃣", "🇩🇪", "😂😂"] + { + assert!(is_emoji_only(reaction), "{reaction:?} is a reaction"); + } + for reply in ["", "nice", "nice 🔥", "12", "#", "🔥 wow"] { + assert!(!is_emoji_only(reply), "{reply:?} is a reply"); + } + } + + #[test] + fn words_what_happened_to_a_story() { + let mut reaction = pending_row("story_reaction"); + reaction.content = Some("🔥".into()); + assert_eq!( + localized_body("en", &reaction), + "has reacted with 🔥 to your story." + ); + assert_eq!( + localized_body("de", &reaction), + "hat mit 🔥 auf deine Story reagiert." + ); + assert_eq!( + localized_body("en", &pending_row("story_reply")), + "has replied to your story." + ); + assert_eq!( + localized_body("de", &pending_row("stored_story")), + "hat deine Story gespeichert." + ); + } + + #[tokio::test] + async fn answers_to_the_own_story_are_announced_as_such() -> Result<()> { + let directory = tempfile::tempdir()?; + let ctx = + Context::init_for_testing(directory.path().join("db"), directory.path().join("data")) + .await?; + UserConfig::save_json( + &ctx, + r#"{"userId":42,"username":"ben","displayName":"Ben"}"#, + )?; + let database = ctx.app_db.read().await.clone(); + let mut tx = database.pool.begin().await?; + for statement in [ + "INSERT INTO contacts(user_id, username, accepted) VALUES (7, 'anna', 1)", + "INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES ('d', 'Anna', 1)", + "INSERT INTO messages(message_id, group_id, type, is_story) VALUES ('mine', 'd', 'media', 1)", + "INSERT INTO messages(message_id, group_id, type, content) VALUES ('plain', 'd', 'text', 'hi')", + ] { + sqlx::query(sqlx::AssertSqlSafe(statement)) + .execute(&mut *tx) + .await?; + } + let text = |id: &str, text: &str, quote: &str| proto::EncryptedContent { + group_id: Some("d".into()), + text_message: Some(encrypted_content::TextMessage { + sender_message_id: id.into(), + text: text.into(), + quote_message_id: Some(quote.into()), + ..Default::default() + }), + ..Default::default() + }; + record_incoming_event(&ctx, &mut tx, 7, "r1", &text("t1", "🔥", "mine")).await?; + record_incoming_event(&ctx, &mut tx, 7, "r2", &text("t2", "love it", "mine")).await?; + record_incoming_event(&ctx, &mut tx, 7, "r3", &text("t3", "🔥", "plain")).await?; + let stored = proto::EncryptedContent { + media_update: Some(encrypted_content::MediaUpdate { + r#type: encrypted_content::media_update::Type::Stored as i32, + target_message_id: "mine".into(), + }), + ..Default::default() + }; + record_incoming_event(&ctx, &mut tx, 7, "r4", &stored).await?; + + let rows: Vec<(String, Option)> = + sqlx::query_as("SELECT kind, content FROM notification_outbox ORDER BY event_id") + .fetch_all(&mut *tx) + .await?; + assert_eq!( + rows, + vec![ + ("story_reaction".into(), Some("🔥".into())), + ("story_reply".into(), None), + // An emoji on anything else is an ordinary reply. + ("response".into(), None), + ("stored_story".into(), None), + ] + ); + Ok(()) + } + + #[test] + fn announces_a_story_in_every_language() { + let row = pending_row("story"); + assert_eq!(localized_body("en", &row), "posted a story."); + assert_eq!(localized_body("de", &row), "hat eine Story gepostet."); + assert_eq!(localized_body("ar", &row), "قصة جديدة."); + } + #[test] fn localizes_connection_fallback_notification() { assert_eq!( diff --git a/rust/src/services/stories.rs b/rust/src/services/stories.rs new file mode 100644 index 00000000..276c86e7 --- /dev/null +++ b/rust/src/services/stories.rs @@ -0,0 +1,477 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +//! Stories: an image or video shared with many contacts for 24 hours. +//! +//! A story item travels the ordinary media path. Each recipient gets a hidden +//! message row in their 1:1 chat -- on the sender one row per recipient, all +//! sharing one media file -- so upload, receipts and opened tracking need +//! nothing of their own. The chat shows a story row only once somebody saved +//! it; every other story row leaves 24 hours after the sender's timestamp. + +use crate::api::proto::client::{encrypted_content, EncryptedContent}; +use crate::context::Context; +use crate::database::app::tables::{Contact, Group}; +use crate::error::{Result, TwonlyError}; +use crate::services::mediafiles::MediaFileService; +use crate::services::messages::MessageService; +use prost::Message as _; +use sqlx::{Sqlite, Transaction}; +use std::collections::BTreeSet; +use std::sync::Arc; + +/// How long a story item is visible after its sender posted it. +pub const STORY_LIFETIME_SECONDS: i64 = 24 * 60 * 60; + +/// A story is announced only between people who sent each other a text or +/// media message within this long. +pub const STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS: i64 = 14 * 24 * 60 * 60; + +/// Whether the 1:1 chat `group_id` carried a text or media message, either +/// way, within the last two weeks. +pub(crate) async fn exchanged_recently( + t: &mut Transaction<'_, Sqlite>, + group_id: &str, + now: i64, +) -> Result { + let since = now - STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS; + Ok(sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM groups WHERE group_id = ? AND last_text_or_media_at > ? + ) AS "recent!: bool""#, + group_id, + since, + ) + .fetch_one(&mut **t) + .await?) +} + +/// Who a story item goes to: every contact, or the members of the selected +/// contact groups that share stories. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StoryAudience { + pub all: bool, + pub contact_group_ids: Vec, +} + +/// The contacts `audience` reaches, in ascending order. Blocked and deleted +/// contacts are left out, as are group members who left. +pub(crate) async fn resolve_audience( + t: &mut Transaction<'_, Sqlite>, + audience: &StoryAudience, +) -> Result> { + let reachable = sqlx::query_scalar!( + r#"SELECT user_id FROM contacts + WHERE accepted = 1 AND blocked = 0 AND deleted_by_user = 0 AND account_deleted = 0"# + ) + .fetch_all(&mut **t) + .await? + .into_iter() + .collect::>(); + if audience.all { + return Ok(reachable.into_iter().collect()); + } + + let mut members = BTreeSet::new(); + for contact_group_id in &audience.contact_group_ids { + let direct = sqlx::query_scalar!( + r#"SELECT cgm.user_id AS "user_id!: i64" + FROM contact_group_members cgm + JOIN contact_groups cg ON cg.id = cgm.contact_group_id + WHERE cg.id = ? AND cg.share_stories = 1 AND cgm.user_id IS NOT NULL"#, + contact_group_id, + ) + .fetch_all(&mut **t) + .await?; + let via_chats = sqlx::query_scalar!( + r#"SELECT gm.contact_id + FROM contact_group_members cgm + JOIN contact_groups cg ON cg.id = cgm.contact_group_id + JOIN group_members gm ON gm.group_id = cgm.group_id + WHERE cg.id = ? AND cg.share_stories = 1 AND cgm.group_id IS NOT NULL + AND (gm.member_state IS NULL OR gm.member_state != 'leftGroup')"#, + contact_group_id, + ) + .fetch_all(&mut **t) + .await?; + members.extend(direct); + members.extend(via_chats); + } + Ok(members.intersection(&reachable).copied().collect()) +} + +/// The 1:1 chats a story sent to `audience` would go to right now, one per +/// contact it reaches. Chats not created yet are included by the id they +/// will get, so the list lines up with chats picked for a direct send. +pub async fn audience_chats(ctx: &Arc, audience: &StoryAudience) -> Result> { + let user_id = ctx.user_id().await?; + let database = ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + let contacts = resolve_audience(&mut transaction, audience).await?; + transaction.rollback().await?; + Ok(contacts + .into_iter() + .map(|contact_id| Group::direct_chat_id(user_id, contact_id)) + .collect()) +} + +/// The 1:1 chat a story row for `contact_id` is filed under, created if the +/// two never wrote to each other. +pub(crate) async fn direct_chat( + ctx: &Context, + t: &mut Transaction<'_, Sqlite>, + contact_id: i64, +) -> Result { + let group_id = Group::direct_chat_id(ctx.user_id().await?, contact_id); + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM groups WHERE group_id = ?) AS "exists!: bool""#, + group_id, + ) + .fetch_one(&mut **t) + .await?; + if !exists { + let contact = Contact::get_contact_by_id(t, contact_id) + .await? + .ok_or_else(|| TwonlyError::Generic(format!("contact {contact_id} does not exist")))?; + Group::create_direct_chat(ctx, t, contact).await?; + } + Ok(group_id) +} + +/// Removes story rows that are no longer a story. A row some message quotes is +/// kept as a tombstone without media, so the quote can say the story is gone +/// instead of pointing at nothing. Returns the media the rows let go of. +async fn retire_rows( + t: &mut Transaction<'_, Sqlite>, + message_ids: &[String], + now: i64, +) -> Result> { + let mut released = BTreeSet::new(); + for message_id in message_ids { + if let Some(media_id) = sqlx::query_scalar!( + "SELECT media_id FROM messages WHERE message_id = ?", + message_id, + ) + .fetch_optional(&mut **t) + .await? + .flatten() + { + released.insert(media_id); + } + // A tombstone keeps its row, so nothing cascades to what hangs off it. + sqlx::query!("DELETE FROM receipts WHERE message_id = ?", message_id) + .execute(&mut **t) + .await?; + sqlx::query!( + r#"UPDATE notification_outbox SET cleared_at = ? + WHERE message_id = ? AND kind = 'story' AND cleared_at IS NULL"#, + now, + message_id, + ) + .execute(&mut **t) + .await?; + let quoted = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM messages WHERE quotes_message_id = ?) AS "quoted!: bool""#, + message_id, + ) + .fetch_one(&mut **t) + .await?; + if quoted { + sqlx::query!( + "UPDATE messages SET media_id = NULL WHERE message_id = ?", + message_id, + ) + .execute(&mut **t) + .await?; + } else { + sqlx::query!("DELETE FROM messages WHERE message_id = ?", message_id) + .execute(&mut **t) + .await?; + } + } + Ok(released) +} + +/// Deletes the media files that `retire_rows` let go of and nothing else holds. +async fn release_media( + ctx: &Arc, + t: &mut Transaction<'_, Sqlite>, + media_ids: BTreeSet, +) -> Result<()> { + for media_id in media_ids { + let media = sqlx::query!( + r#"SELECT type AS media_type, stored, is_draft_media FROM media_files + WHERE media_id = ? + AND NOT EXISTS (SELECT 1 FROM messages WHERE media_id = media_files.media_id)"#, + media_id, + ) + .fetch_optional(&mut **t) + .await?; + let Some(media) = media else { + continue; + }; + if media.stored != 0 || media.is_draft_media != 0 { + continue; + } + sqlx::query!("DELETE FROM media_files WHERE media_id = ?", media_id) + .execute(&mut **t) + .await?; + MediaFileService::new(ctx).remove_files(&media_id, &media.media_type)?; + } + Ok(()) +} + +/// Lets every story item older than 24 hours go, on both ends: a received one +/// disappears from its sender's ring, and an own one stops being retried for +/// recipients that never acknowledged it. Saved items stay, as ordinary stored +/// media in their chat. +pub async fn purge_expired(ctx: &Arc) -> Result<()> { + let now = chrono::Utc::now().timestamp(); + let cutoff = now - STORY_LIFETIME_SECONDS; + let database = ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + let expired = sqlx::query_scalar!( + r#"SELECT message_id FROM messages + WHERE is_story = 1 AND media_stored = 0 AND created_at <= ?"#, + cutoff, + ) + .fetch_all(&mut *transaction) + .await?; + if expired.is_empty() { + return Ok(()); + } + let released = retire_rows(&mut transaction, &expired, now).await?; + release_media(ctx, &mut transaction, released).await?; + transaction.commit().await?; + tracing::info!(count = expired.len(), "purged expired story rows"); + Ok(()) +} + +/// Takes one of the user's own story items down before it expires: every +/// recipient is told to drop it, and the item goes here too. Items whose upload +/// has not finished are refused, since a delete could overtake the media and +/// the item would then arrive after all. +pub async fn delete_item(ctx: &Arc, media_id: &str) -> Result<()> { + let database = ctx.app_db.read().await.clone(); + let upload_state = sqlx::query_scalar!( + "SELECT upload_state FROM media_files WHERE media_id = ?", + media_id, + ) + .fetch_optional(&database.pool) + .await? + .flatten(); + if upload_state.as_deref() != Some("uploaded") { + return Err(TwonlyError::Generic( + "a story item can only be deleted once its upload finished".into(), + )); + } + let rows = sqlx::query!( + r#"SELECT message_id, group_id FROM messages + WHERE media_id = ? AND is_story = 1 AND sender_id IS NULL AND media_stored = 0"#, + media_id, + ) + .fetch_all(&database.pool) + .await?; + drop(database); + + let timestamp = chrono::Utc::now().timestamp_millis(); + let messages = MessageService::new(ctx); + for row in &rows { + let content = EncryptedContent { + message_update: Some(encrypted_content::MessageUpdate { + r#type: encrypted_content::message_update::Type::Delete as i32, + sender_message_id: Some(row.message_id.clone()), + multiple_target_message_ids: Vec::new(), + text: None, + timestamp, + }), + ..Default::default() + }; + messages + .send_to_group(row.group_id.clone(), content.encode_to_vec(), None, false) + .await?; + } + + let message_ids = rows + .into_iter() + .map(|row| row.message_id) + .collect::>(); + let database = ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + let released = retire_rows(&mut transaction, &message_ids, timestamp / 1_000).await?; + release_media(ctx, &mut transaction, released).await?; + transaction.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn context() -> Result<(tempfile::TempDir, Arc)> { + let directory = tempfile::tempdir()?; + let ctx = + Context::init_for_testing(directory.path().join("db"), directory.path().join("data")) + .await?; + crate::user_config::UserConfig::save_json( + &ctx, + r#"{"userId":42,"username":"ben","displayName":"Ben"}"#, + )?; + ctx.inject_test_user_id(42).await?; + Ok((directory, ctx)) + } + + async fn execute(ctx: &Context, statements: &[&str]) -> Result<()> { + let database = ctx.app_db.read().await.clone(); + for statement in statements { + sqlx::query(sqlx::AssertSqlSafe(*statement)) + .execute(&database.pool) + .await?; + } + Ok(()) + } + + #[tokio::test] + async fn an_audience_reaches_reachable_contacts_of_story_groups() -> Result<()> { + let (_directory, ctx) = context().await?; + execute( + &ctx, + &[ + "INSERT INTO contacts(user_id, username, accepted) VALUES (1, 'a', 1)", + "INSERT INTO contacts(user_id, username, accepted, blocked) VALUES (2, 'b', 1, 1)", + "INSERT INTO contacts(user_id, username, accepted, deleted_by_user) VALUES (3, 'c', 1, 1)", + "INSERT INTO contacts(user_id, username) VALUES (4, 'd')", + "INSERT INTO contacts(user_id, username, accepted, account_deleted) VALUES (5, 'e', 1, 1)", + "INSERT INTO contacts(user_id, username, accepted) VALUES (6, 'f', 1)", + "INSERT INTO contacts(user_id, username, accepted) VALUES (7, 'g', 1)", + "INSERT INTO contacts(user_id, username, accepted) VALUES (8, 'h', 1)", + "INSERT INTO groups(group_id, group_name) VALUES ('trip', 'Trip')", + "INSERT INTO group_members(group_id, contact_id) VALUES ('trip', 7)", + "INSERT INTO group_members(group_id, contact_id, member_state) VALUES ('trip', 8, 'leftGroup')", + "INSERT INTO group_members(group_id, contact_id) VALUES ('trip', 2)", + "INSERT INTO contact_groups(id, name, text_color, background_color, share_stories) VALUES (1, 'Close', 0, 0, 1)", + "INSERT INTO contact_groups(id, name, text_color, background_color, share_stories) VALUES (2, 'Work', 0, 0, 0)", + "INSERT INTO contact_group_members(contact_group_id, user_id) VALUES (1, 1)", + "INSERT INTO contact_group_members(contact_group_id, user_id) VALUES (1, 4)", + "INSERT INTO contact_group_members(contact_group_id, group_id) VALUES (1, 'trip')", + "INSERT INTO contact_group_members(contact_group_id, user_id) VALUES (2, 6)", + ], + ) + .await?; + let database = ctx.app_db.read().await.clone(); + let mut t = database.pool.begin().await?; + let resolve = |all: bool, groups: &[i64]| StoryAudience { + all, + contact_group_ids: groups.to_vec(), + }; + + assert_eq!( + resolve_audience(&mut t, &resolve(true, &[])).await?, + vec![1, 6, 7, 8] + ); + // Blocked (2) and not accepted (4) members stay out, and so does the + // member who left the chat group (8). + assert_eq!( + resolve_audience(&mut t, &resolve(false, &[1])).await?, + vec![1, 7] + ); + // A group that does not share stories reaches nobody. + assert_eq!( + resolve_audience(&mut t, &resolve(false, &[2])).await?, + Vec::::new() + ); + assert_eq!( + resolve_audience(&mut t, &resolve(false, &[1, 2])).await?, + vec![1, 7] + ); + Ok(()) + } + + #[tokio::test] + async fn an_audience_is_named_by_the_chats_it_reaches() -> Result<()> { + let (_directory, ctx) = context().await?; + execute( + &ctx, + &[ + "INSERT INTO contacts(user_id, username, accepted) VALUES (1, 'a', 1)", + "INSERT INTO contacts(user_id, username, accepted) VALUES (77, 'b', 1)", + "INSERT INTO contacts(user_id, username) VALUES (3, 'c')", + ], + ) + .await?; + let everybody = StoryAudience { + all: true, + contact_group_ids: vec![], + }; + // No chat with either exists yet; the ids are the ones they will get, + // so they can be compared with chats picked for a direct send. + assert_eq!( + audience_chats(&ctx, &everybody).await?, + vec![Group::direct_chat_id(42, 1), Group::direct_chat_id(42, 77)] + ); + Ok(()) + } + + #[tokio::test] + async fn expired_story_rows_leave_unless_saved_or_quoted() -> Result<()> { + let (_directory, ctx) = context().await?; + let now = chrono::Utc::now().timestamp(); + let expired = now - STORY_LIFETIME_SECONDS - 60; + let fresh = now - 60; + let statements = [ + "INSERT INTO contacts(user_id, username, accepted) VALUES (7, 'anna', 1)".to_owned(), + "INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES ('d', 'Anna', 1)" + .to_owned(), + "INSERT INTO media_files(media_id, type) VALUES ('m1', 'image'), ('m2', 'image'), ('m3', 'image'), ('m4', 'image')".to_owned(), + format!("INSERT INTO messages(message_id, group_id, sender_id, type, media_id, is_story, created_at) VALUES ('gone', 'd', 7, 'media', 'm1', 1, {expired})"), + format!("INSERT INTO messages(message_id, group_id, sender_id, type, media_id, is_story, created_at) VALUES ('quoted', 'd', 7, 'media', 'm2', 1, {expired})"), + format!("INSERT INTO messages(message_id, group_id, sender_id, type, media_id, is_story, media_stored, created_at) VALUES ('saved', 'd', 7, 'media', 'm3', 1, 1, {expired})"), + format!("INSERT INTO messages(message_id, group_id, sender_id, type, media_id, is_story, created_at) VALUES ('live', 'd', 7, 'media', 'm4', 1, {fresh})"), + "INSERT INTO messages(message_id, group_id, type, content, quotes_message_id) VALUES ('reply', 'd', 'text', '🔥', 'quoted')".to_owned(), + "INSERT INTO receipts(receipt_id, contact_id, message_id, message) VALUES ('r1', 7, 'gone', x'00'), ('r2', 7, 'quoted', x'00')".to_owned(), + ]; + let statements = statements.iter().map(String::as_str).collect::>(); + execute(&ctx, &statements).await?; + + purge_expired(&ctx).await?; + + let database = ctx.app_db.read().await.clone(); + let rows: Vec<(String, Option)> = + sqlx::query_as("SELECT message_id, media_id FROM messages ORDER BY message_id") + .fetch_all(&database.pool) + .await?; + assert_eq!( + rows, + vec![ + ("live".into(), Some("m4".into())), + // Kept so the reply can still say what it answered. + ("quoted".into(), None), + ("reply".into(), None), + ("saved".into(), Some("m3".into())), + ] + ); + let media: Vec = + sqlx::query_scalar("SELECT media_id FROM media_files ORDER BY media_id") + .fetch_all(&database.pool) + .await?; + assert_eq!(media, vec!["m3".to_owned(), "m4".to_owned()]); + let receipts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM receipts") + .fetch_one(&database.pool) + .await?; + assert_eq!(receipts, 0); + + // Once nothing quotes the tombstone any more, it goes too. + sqlx::query("DELETE FROM messages WHERE message_id = 'reply'") + .execute(&database.pool) + .await?; + purge_expired(&ctx).await?; + let quoted: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE message_id = 'quoted'") + .fetch_one(&database.pool) + .await?; + assert_eq!(quoted, 0); + Ok(()) + } +} diff --git a/rust/src/user_config.rs b/rust/src/user_config.rs index 035365af..f528b4a1 100644 --- a/rust/src/user_config.rs +++ b/rust/src/user_config.rs @@ -259,6 +259,11 @@ pub struct UserConfig { #[serde(default = "defaults::true_value")] #[frb(non_final)] pub typing_indicators: bool, + /// Announce a contact's first story in 24 hours. Checked only on this + /// device, where the notification is created; senders always wake. + #[serde(default = "defaults::true_value")] + #[frb(non_final)] + pub story_notifications: bool, #[serde(default = "defaults::true_value")] #[frb(non_final)] pub show_restore_flame: bool, diff --git a/rust/tests/api.rs b/rust/tests/api.rs index e28d6e4b..b61c4723 100644 --- a/rust/tests/api.rs +++ b/rust/tests/api.rs @@ -14,6 +14,8 @@ mod recovery; mod server_api; #[path = "api/session_recovery.rs"] mod session_recovery; +#[path = "api/stories.rs"] +mod stories; #[path = "api/tester.rs"] mod tester; #[path = "api/user_discovery.rs"] diff --git a/rust/tests/api/stories.rs b/rust/tests/api/stories.rs new file mode 100644 index 00000000..e20efb67 --- /dev/null +++ b/rust/tests/api/stories.rs @@ -0,0 +1,409 @@ +use super::{init_tracing, Tester}; +use prost::Message as _; +use rust_lib_twonly::api::messages::outgoing::send_c2c_message_to_contact; +use rust_lib_twonly::api::proto::client::{encrypted_content, EncryptedContent}; +use rust_lib_twonly::bridge::api::ApiConnectionState; +use rust_lib_twonly::database::app::tables::Group; +use rust_lib_twonly::services::contacts::ContactService; +use rust_lib_twonly::services::media_upload::MediaUploadService; +use rust_lib_twonly::services::messages::MessageService; +use rust_lib_twonly::services::stories::{self, StoryAudience, STORY_LIFETIME_SECONDS}; +use tokio::time::{sleep, Duration}; + +async fn create_authenticated_tester() -> anyhow::Result { + let mut tester = Tester::new().await?; + tester.wait_until(ApiConnectionState::Connected).await?; + tester.register_and_authenticate().await?; + tester.wait_until(ApiConnectionState::Authenticated).await?; + Ok(tester) +} + +async fn connect(requester: &Tester, accepter: &Tester) -> anyhow::Result<()> { + ContactService::new(&requester.context) + .request_by_username(accepter.username.clone(), true) + .await?; + accepter + .wait_for_contact_state(requester.user_id, false, true) + .await?; + ContactService::new(&accepter.context) + .accept_request(requester.user_id, true) + .await?; + requester + .wait_for_contact_state(accepter.user_id, true, false) + .await?; + Ok(()) +} + +async fn scalar(tester: &Tester, sql: &str, bind: &str) -> anyhow::Result { + let database = tester.context.app_db.read().await.clone(); + Ok( + sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(sql.to_owned())) + .bind(bind) + .fetch_one(&database.pool) + .await?, + ) +} + +/// Polls until `sql` (with one bound value) returns something other than 0. +async fn wait_for(tester: &Tester, what: &str, sql: &str, bind: &str) -> anyhow::Result<()> { + for _ in 0..100 { + if scalar(tester, sql, bind).await? != 0 { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + Err(anyhow::anyhow!("{what} did not happen")) +} + +/// Files a story row for `recipient` on `sender`, as `insert_into_messages` +/// does, and sends the envelope `prepare_media` builds for it. Only the +/// envelope travels: on this host a media upload stops before the native +/// transfer, so the attachment itself never reaches the server. +async fn post_story( + sender: &Tester, + recipient: &Tester, + message_id: &str, + media_id: &str, + posted_at: i64, + notify: bool, +) -> anyhow::Result<()> { + let group_id = Group::direct_chat_id(sender.user_id, recipient.user_id); + { + let database = sender.context.app_db.read().await.clone(); + sqlx::query( + "INSERT OR IGNORE INTO media_files(media_id, type, upload_state) VALUES (?, 'image', 'uploaded')", + ) + .bind(media_id) + .execute(&database.pool) + .await?; + sqlx::query( + "INSERT INTO messages(group_id, message_id, type, media_id, is_story, created_at) VALUES (?, ?, 'media', ?, 1, ?)", + ) + .bind(&group_id) + .bind(message_id) + .bind(media_id) + .bind(posted_at) + .execute(&database.pool) + .await?; + } + let content = EncryptedContent { + story: Some(encrypted_content::Story { + media: Some(encrypted_content::Media { + sender_message_id: message_id.to_owned(), + r#type: encrypted_content::media::Type::Image as i32, + timestamp: posted_at * 1_000, + download_token: Some(vec![1; 32]), + encryption_key: Some(vec![2; 32]), + encryption_mac: Some(vec![3; 16]), + encryption_nonce: Some(vec![4; 12]), + ..Default::default() + }), + notify, + }), + ..Default::default() + }; + send_c2c_message_to_contact() + .ctx(&sender.context) + .contact_id(recipient.user_id) + .encrypted_content(content.encode_to_vec()) + .message_id(message_id.to_owned()) + .call() + .await?; + Ok(()) +} + +const STORY_ROW: &str = "SELECT COUNT(*) FROM messages WHERE message_id = ? AND is_story = 1"; +const STORY_OUTBOX: &str = + "SELECT COUNT(*) FROM notification_outbox WHERE kind = 'story' AND message_id = ?"; + +/// A posts to B and to C, who muted story notifications. Everything below +/// crosses the dev server between three real clients. +#[tokio::test] +async fn test_story_lifecycle_between_clients() -> anyhow::Result<()> { + init_tracing(); + + let tester_a = create_authenticated_tester().await?; + let tester_b = create_authenticated_tester().await?; + let tester_c = create_authenticated_tester().await?; + tester_c.set_story_notifications(false)?; + connect(&tester_a, &tester_b).await?; + connect(&tester_a, &tester_c).await?; + let chat_ab = Group::direct_chat_id(tester_a.user_id, tester_b.user_id); + let chat_ac = Group::direct_chat_id(tester_a.user_id, tester_c.user_id); + + // + // 1. The real send path: "All" becomes one hidden row per contact, and + // preparation encrypts one envelope per recipient. + // + let uploads = MediaUploadService::new(&tester_a.context); + let media_id = uploads.initialize("image".into(), None, false).await?; + let plaintext_path = std::path::PathBuf::from(&tester_a.context.config.data_dir) + .join("mediafiles/tmp") + .join(format!("{media_id}.webp")); + std::fs::create_dir_all(plaintext_path.parent().expect("tmp directory"))?; + let source = image::RgbImage::from_pixel(8, 6, image::Rgb([12, 34, 56])); + let plaintext = webp::Encoder::from_rgb(source.as_raw(), source.width(), source.height()) + .encode_simple(false, 80.0) + .map_err(|error| anyhow::anyhow!("could not encode media fixture: {error:?}"))?; + std::fs::write(&plaintext_path, plaintext.as_ref())?; + + let all = StoryAudience { + all: true, + contact_group_ids: Vec::new(), + }; + // A widget send cannot also be a story. + assert!(uploads + .insert_into_messages(media_id.clone(), Vec::new(), None, true, Some(all.clone())) + .await + .is_err()); + uploads + .insert_into_messages(media_id.clone(), Vec::new(), None, false, Some(all)) + .await?; + { + let database = tester_a.context.app_db.read().await.clone(); + let mut groups: Vec = sqlx::query_scalar( + "SELECT group_id FROM messages WHERE media_id = ? AND is_story = 1 AND sender_id IS NULL", + ) + .bind(&media_id) + .fetch_all(&database.pool) + .await?; + groups.sort(); + let mut expected = vec![chat_ab.clone(), chat_ac.clone()]; + expected.sort(); + assert_eq!(groups, expected, "one hidden story row per contact"); + } + tester_a.wait_for_media_encryption_mac(&media_id).await?; + wait_for( + &tester_a, + "an envelope for every story recipient", + "SELECT COUNT(*) = 2 FROM receipts r JOIN messages m ON m.message_id = r.message_id + WHERE m.media_id = ? AND r.will_be_retried_by_media_upload = 1", + &media_id, + ) + .await?; + tester_a.wait_for_no_pending_upload_jobs().await?; + // Still uploading, so it cannot be taken down yet. + assert!(stories::delete_item(&tester_a.context, &media_id) + .await + .is_err()); + + // + // 2. A story is announced only between people who wrote to each other + // in the last two weeks. A and B have not yet: filed, not announced. + // + let now = chrono::Utc::now().timestamp(); + post_story(&tester_a, &tester_b, "s-quiet-b", "s-quiet", now, true).await?; + wait_for( + &tester_b, + "the quiet story to be filed", + STORY_ROW, + "s-quiet-b", + ) + .await?; + assert_eq!(scalar(&tester_b, STORY_OUTBOX, "s-quiet-b").await?, 0); + + // One text is enough; C, who muted stories, gets one as well. + for (recipient, text) in [(&tester_b, "hi b"), (&tester_c, "hi c")] { + let chat = Group::direct_chat_id(tester_a.user_id, recipient.user_id); + let text_id = MessageService::new(&tester_a.context) + .insert_and_send_text(chat, text.into(), None, None) + .await?; + recipient + .wait_for_text_message(&text_id, tester_a.user_id, text) + .await?; + } + + // + // 3. Only the item the sender woke for is announced, and a muted + // receiver files stories without announcing any. + // + for (recipient, suffix) in [(&tester_b, "b"), (&tester_c, "c")] { + post_story( + &tester_a, + recipient, + &format!("s1-{suffix}"), + "s1", + now, + true, + ) + .await?; + post_story( + &tester_a, + recipient, + &format!("s2-{suffix}"), + "s2", + now, + false, + ) + .await?; + } + for (recipient, suffix) in [(&tester_b, "b"), (&tester_c, "c")] { + for item in ["s1", "s2"] { + let message_id = format!("{item}-{suffix}"); + wait_for(recipient, "the story to be filed", STORY_ROW, &message_id).await?; + } + } + tester_b + .wait_for_notification("story", tester_a.user_id) + .await?; + assert_eq!(scalar(&tester_b, STORY_OUTBOX, "s1-b").await?, 1); + assert_eq!(scalar(&tester_b, STORY_OUTBOX, "s2-b").await?, 0); + assert_eq!(scalar(&tester_c, STORY_OUTBOX, "s1-c").await?, 0); + // Filed under the direct chat, and not part of it. + assert_eq!( + scalar( + &tester_b, + "SELECT COUNT(*) FROM messages WHERE group_id = ? AND is_story = 1", + &chat_ab + ) + .await?, + 3 + ); + + // + // 4. A story that arrives after its 24 hours is ignored without a receipt; + // the sender's purge is what stops it being retried. + // + let expired = now - STORY_LIFETIME_SECONDS - 60; + post_story(&tester_a, &tester_b, "s0-b", "s0", expired, true).await?; + post_story(&tester_a, &tester_b, "s3-b", "s3", now, false).await?; + wait_for(&tester_b, "the later story to be filed", STORY_ROW, "s3-b").await?; + wait_for( + &tester_a, + "the later story to be acknowledged", + "SELECT COUNT(*) FROM message_actions WHERE message_id = ? AND type = 'ackByUserAt'", + "s3-b", + ) + .await?; + assert_eq!( + scalar( + &tester_b, + "SELECT COUNT(*) FROM messages WHERE message_id = ?", + "s0-b" + ) + .await?, + 0 + ); + assert_eq!( + scalar( + &tester_a, + "SELECT COUNT(*) FROM message_actions WHERE message_id = ? AND type = 'ackByUserAt'", + "s0-b" + ) + .await?, + 0, + "an expired story is never acknowledged" + ); + assert_eq!( + scalar( + &tester_a, + "SELECT COUNT(*) FROM receipts WHERE message_id = ?", + "s0-b" + ) + .await?, + 1 + ); + stories::purge_expired(&tester_a.context).await?; + assert_eq!( + scalar( + &tester_a, + "SELECT COUNT(*) FROM receipts WHERE message_id = ?", + "s0-b" + ) + .await?, + 0 + ); + assert_eq!( + scalar( + &tester_a, + "SELECT COUNT(*) FROM messages WHERE message_id = ?", + "s0-b" + ) + .await?, + 0 + ); + + // + // 5. What a viewer does comes back to the sender: opening, saving, and a + // reaction as a message quoting the story. + // + MessageService::new(&tester_b.context) + .notify_opened(tester_a.user_id, vec!["s1-b".into()]) + .await?; + wait_for( + &tester_a, + "B's view to be recorded", + "SELECT COUNT(*) FROM message_actions WHERE message_id = ? AND type = 'openedAt'", + "s1-b", + ) + .await?; + // Opening the story clears its announcement on B. + assert_eq!( + scalar( + &tester_b, + "SELECT COUNT(*) FROM notification_outbox WHERE message_id = ? AND cleared_at IS NULL", + "s1-b" + ) + .await?, + 0 + ); + + let stored = EncryptedContent { + media_update: Some(encrypted_content::MediaUpdate { + r#type: encrypted_content::media_update::Type::Stored as i32, + target_message_id: "s1-b".into(), + }), + ..Default::default() + }; + send_c2c_message_to_contact() + .ctx(&tester_b.context) + .contact_id(tester_a.user_id) + .encrypted_content(stored.encode_to_vec()) + .call() + .await?; + tester_a.wait_for_media_stored("s1-b").await?; + // Each of these reaches A worded for what it is about: A's story. + tester_a + .wait_for_notification("stored_story", tester_b.user_id) + .await?; + + let reaction_id = MessageService::new(&tester_b.context) + .insert_and_send_text(chat_ab.clone(), "🔥".into(), Some("s1-b".into()), None) + .await?; + tester_a + .wait_for_quoted_text_message(&reaction_id, tester_b.user_id, "🔥", "s1-b") + .await?; + let reaction = tester_a + .wait_for_notification("story_reaction", tester_b.user_id) + .await?; + assert_eq!(reaction.body, "has reacted with 🔥 to your story."); + + let reply_id = MessageService::new(&tester_b.context) + .insert_and_send_text(chat_ab.clone(), "love it".into(), Some("s1-b".into()), None) + .await?; + tester_a + .wait_for_quoted_text_message(&reply_id, tester_b.user_id, "love it", "s1-b") + .await?; + let reply = tester_a + .wait_for_notification("story_reply", tester_b.user_id) + .await?; + assert_eq!(reply.body, "has replied to your story."); + + // + // 6. Taking an item down removes it for every recipient. + // + stories::delete_item(&tester_a.context, "s2").await?; + tester_b.wait_for_message_deleted("s2-b").await?; + tester_c.wait_for_message_deleted("s2-c").await?; + assert_eq!( + scalar( + &tester_a, + "SELECT COUNT(*) FROM messages WHERE media_id = ?", + "s2" + ) + .await?, + 0 + ); + + Ok(()) +} diff --git a/rust/tests/api/tester.rs b/rust/tests/api/tester.rs index 94c45930..1d455ccc 100644 --- a/rust/tests/api/tester.rs +++ b/rust/tests/api/tester.rs @@ -448,6 +448,8 @@ impl Tester { user_discovery_threshold: 3, user_discovery_share_promotion: true, typing_indicators: true, + // What a real account starts with; `Default` would mute stories. + story_notifications: true, ..Default::default() }; std::fs::write( @@ -502,6 +504,22 @@ impl Tester { Ok(()) } + /// Flips the receiver-side story notification setting, as the settings + /// switch does. + pub fn set_story_notifications(&self, enabled: bool) -> anyhow::Result<()> { + let path = self + ._temp_dir + .path() + .join("data") + .join("keyvalue") + .join("user.json"); + let content = std::fs::read_to_string(&path)?; + let mut config: rust_lib_twonly::user_config::UserConfig = serde_json::from_str(&content)?; + config.story_notifications = enabled; + std::fs::write(&path, serde_json::to_string(&config)?)?; + Ok(()) + } + pub fn update_profile( &mut self, new_username: Option, diff --git a/scripts/fix_adb_connection.sh b/scripts/fix_adb_connection.sh new file mode 100755 index 00000000..3f3682d0 --- /dev/null +++ b/scripts/fix_adb_connection.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Recover an Android wireless-debugging connection when `adb connect` reports +# "No route to host" even though the device's TCP port is reachable. +# +# Usage: +# scripts/fix_adb_connection.sh [PORT] +# scripts/fix_adb_connection.sh [HOST:PORT] +# +# The defaults below are the most recently used development device. + +set -u + +DEFAULT_HOST="10.99.0.213" +DEFAULT_PORT="34483" +MAX_ATTEMPTS=3 + +usage() { + sed -n '2,10p' "$0" +} + +case "${1:-}" in + -h|--help) + usage + exit 0 + ;; +esac + +HOST="$DEFAULT_HOST" +PORT="$DEFAULT_PORT" + +if [ "$#" -gt 2 ]; then + usage >&2 + exit 2 +fi + +if [ "$#" -eq 2 ]; then + HOST="$1" + PORT="$2" +elif [ "$#" -eq 1 ]; then + case "$1" in + *:*) + HOST="${1%:*}" + PORT="${1##*:}" + ;; + *[!0-9]*) HOST="$1" ;; + *) PORT="$1" ;; + esac +fi + +case "$PORT" in + ''|*[!0-9]*) + echo "invalid port: $PORT" >&2 + exit 2 + ;; +esac + +if [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then + echo "port must be between 1 and 65535" >&2 + exit 2 +fi + +for bin in adb nc; do + command -v "$bin" >/dev/null || { + echo "missing required tool: $bin" >&2 + exit 1 + } +done + +ENDPOINT="$HOST:$PORT" + +echo "Checking $ENDPOINT directly..." +if [ "$(uname -s)" = "Darwin" ]; then + NC_ARGS=(-G 3 -z) +else + NC_ARGS=(-w 3 -z) +fi + +if ! nc "${NC_ARGS[@]}" "$HOST" "$PORT" >/dev/null 2>&1; then + echo "The TCP port is not reachable, so restarting ADB cannot fix it." >&2 + echo "Check that Wireless debugging is enabled and that $PORT is the current connection port." >&2 + exit 1 +fi +echo "TCP port is reachable." + +# Prevent Bonjour/mDNS from adding a second transport for the same device while +# this script establishes the explicit host:port connection. +export ADB_MDNS_AUTO_CONNECT=0 + +LAST_OUTPUT="" +attempt=1 +while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + echo "ADB reconnect attempt $attempt/$MAX_ATTEMPTS..." + + # `adb kill-server` sometimes leaves the macOS daemon alive. Killing only + # this user's exact `adb` processes clears that stale network-permission + # state without requiring sudo or affecting other users. + adb kill-server >/dev/null 2>&1 || true + pkill -x -u "$(id -u)" adb >/dev/null 2>&1 || true + + adb start-server >/dev/null 2>&1 + adb disconnect "$ENDPOINT" >/dev/null 2>&1 || true + + LAST_OUTPUT="$(adb connect "$ENDPOINT" 2>&1)" + echo "$LAST_OUTPUT" + + if adb -s "$ENDPOINT" get-state 2>/dev/null | grep -qx device; then + echo + echo "Connected successfully: $ENDPOINT" + adb -s "$ENDPOINT" shell getprop ro.product.model 2>/dev/null \ + | sed 's/^/Device: /' + exit 0 + fi + + attempt=$((attempt + 1)) + [ "$attempt" -le "$MAX_ATTEMPTS" ] && sleep 1 +done + +echo >&2 +echo "ADB could not connect after $MAX_ATTEMPTS clean restarts." >&2 +if printf '%s' "$LAST_OUTPUT" | grep -q "No route to host"; then + echo "The port is reachable, but the ADB daemon is being denied local-network access." >&2 + if [ "$(uname -s)" = "Darwin" ]; then + echo "Toggle your terminal app under System Settings > Privacy & Security > Local Network," >&2 + echo "then run this script again." >&2 + fi +fi +exit 1 diff --git a/test/drift/contact_groups_dao_test.dart b/test/drift/contact_groups_dao_test.dart index 34bee03e..c00f10b0 100644 --- a/test/drift/contact_groups_dao_test.dart +++ b/test/drift/contact_groups_dao_test.dart @@ -30,6 +30,7 @@ void main() { backgroundColor: 0, showAsShortcut: true, showAsLabel: true, + shareStories: false, ); await database.contactGroupsDao.replaceMembers( diff --git a/test/drift/stories_dao_test.dart b/test/drift/stories_dao_test.dart new file mode 100644 index 00000000..753be6b4 --- /dev/null +++ b/test/drift/stories_dao_test.dart @@ -0,0 +1,118 @@ +import 'package:clock/clock.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/database/twonly.db.dart'; + +void main() { + late TwonlyDB database; + final now = DateTime(2026, 9, 27, 12); + final nowSeconds = now.millisecondsSinceEpoch ~/ 1000; + + setUp(() async { + database = TwonlyDB(NativeDatabase.memory()); + for (final statement in [ + "INSERT INTO contacts(user_id, username, accepted) VALUES (7, 'anna', 1)", + "INSERT INTO contacts(user_id, username, accepted) VALUES (8, 'ben', 1)", + "INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES ('anna', 'Anna', 1)", + "INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES ('ben', 'Ben', 1)", + "INSERT INTO group_members(group_id, contact_id) VALUES ('anna', 7)", + "INSERT INTO group_members(group_id, contact_id) VALUES ('ben', 8)", + "INSERT INTO media_files(media_id, type) VALUES ('seen', 'image'), ('unseen', 'image'), ('old', 'image'), ('chat', 'image'), ('mine', 'video')", + // Anna's story: one item seen, one not, one long expired. + 'INSERT INTO messages(group_id, message_id, sender_id, type, media_id, is_story, opened_at, created_at) ' + "VALUES ('anna', 'seen', 7, 'media', 'seen', 1, $nowSeconds, ${nowSeconds - 3600})", + 'INSERT INTO messages(group_id, message_id, sender_id, type, media_id, is_story, created_at) ' + "VALUES ('anna', 'unseen', 7, 'media', 'unseen', 1, ${nowSeconds - 60})", + 'INSERT INTO messages(group_id, message_id, sender_id, type, media_id, is_story, created_at) ' + "VALUES ('anna', 'old', 7, 'media', 'old', 1, ${nowSeconds - 2 * 86400})", + // An ordinary snap in the same chat, older than the story. + 'INSERT INTO messages(group_id, message_id, sender_id, type, media_id, created_at) ' + "VALUES ('anna', 'chat', 7, 'media', 'chat', ${nowSeconds - 7200})", + // The user's own story item went to both, and Ben opened it. + 'INSERT INTO messages(group_id, message_id, type, media_id, is_story, created_at) ' + "VALUES ('anna', 'mine-anna', 'media', 'mine', 1, ${nowSeconds - 600})", + 'INSERT INTO messages(group_id, message_id, type, media_id, is_story, opened_at, created_at) ' + "VALUES ('ben', 'mine-ben', 'media', 'mine', 1, $nowSeconds, ${nowSeconds - 600})", + 'INSERT INTO message_actions(message_id, contact_id, type, action_at) ' + "VALUES ('mine-ben', 8, 'openedAt', $nowSeconds)", + ]) { + await database.customStatement(statement); + } + }); + + tearDown(() => database.close()); + + test('received story items skip expired ones, oldest first', () async { + final items = await withClock( + Clock.fixed(now), + () => database.storiesDao.getReceivedStoryItems(7), + ); + expect(items.map((item) => item.message.messageId), ['seen', 'unseen']); + expect(items.map((item) => item.seen), [true, false]); + }); + + test('own story items are grouped per media with their viewers', () async { + final items = await withClock( + Clock.fixed(now), + () => database.storiesDao.watchOwnStoryItems().first, + ); + expect(items, hasLength(1)); + expect(items.single.mediaFile.mediaId, 'mine'); + expect(items.single.recipientCount, 2); + expect(items.single.viewerCount, 1); + + final viewers = await database.storiesDao.watchStoryViewers('mine').first; + expect(viewers.map((viewer) => viewer.contact.userId), [8, 7]); + expect(viewers.first.openedAt, isNotNull); + expect(viewers.last.openedAt, isNull); + }); + + test('the chat list never shows an unsaved story row', () async { + final latest = await database.messagesDao + .watchLatestMessagesByGroup() + .first; + final byGroup = {for (final m in latest) m.groupId: m.messageId}; + // The story rows are newer, but the chat still ends at the snap. + expect(byGroup['anna'], 'chat'); + expect(byGroup.containsKey('ben'), isFalse); + + final unopened = await database.messagesDao + .watchAllMessagesNotOpened() + .first; + expect(unopened.map((m) => m.messageId), ['chat']); + }); + + test('a saved story shows in its chat like any stored media', () async { + await database.customStatement( + "UPDATE messages SET media_stored = 1 WHERE message_id = 'unseen'", + ); + final latest = await database.messagesDao + .watchLatestMessagesByGroup() + .first; + expect( + latest.singleWhere((m) => m.groupId == 'anna').messageId, + 'unseen', + ); + }); + + test( + 'the chat retention purge leaves story rows to the story purge', + () async { + await database.customStatement( + 'UPDATE groups SET delete_messages_after_milliseconds = 1000', + ); + await database.customStatement( + 'UPDATE messages SET opened_by_all = ${nowSeconds - 86400}', + ); + await withClock( + Clock.fixed(now), + () => database.messagesDao.purgeMessageTable(), + ); + final remaining = await database + .customSelect('SELECT message_id FROM messages ORDER BY message_id') + .map((row) => row.read('message_id')) + .get(); + expect(remaining, ['mine-anna', 'mine-ben', 'old', 'seen', 'unseen']); + }, + ); +} diff --git a/test/mocks/user_config.dart b/test/mocks/user_config.dart index c36c2ee9..6e308d1f 100644 --- a/test/mocks/user_config.dart +++ b/test/mocks/user_config.dart @@ -30,6 +30,7 @@ UserConfig testUserConfig({ storeLocationInMemories: false, autoStoreAllSendUnlimitedMediaFiles: false, typingIndicators: true, + storyNotifications: true, showRestoreFlame: true, allowErrorTrackingViaSentry: false, screenLockEnabled: false, diff --git a/test/services/notification_routing_test.dart b/test/services/notification_routing_test.dart index c1475191..c9cd352a 100644 --- a/test/services/notification_routing_test.dart +++ b/test/services/notification_routing_test.dart @@ -7,6 +7,26 @@ void main() { expect(NativeNotificationService.opensConversation('response'), isTrue); }); + test('replies and reactions to the own story open the conversation', () { + for (final kind in ['story_reply', 'story_reaction']) { + expect( + NativeNotificationService.opensConversation(kind), + isTrue, + reason: kind, + ); + } + }); + + test('stories and saves of them stay on the chat overview', () { + for (final kind in ['story', 'stored_story']) { + expect( + NativeNotificationService.opensConversation(kind), + isFalse, + reason: kind, + ); + } + }); + test('media notifications stay on the chat overview', () { for (final kind in ['twonly', 'image', 'video', 'audio']) { expect( diff --git a/test/visual/chat_contacts_entry_test.dart b/test/visual/chat_contacts_entry_test.dart index 53fa9df4..b4b812c7 100644 --- a/test/visual/chat_contacts_entry_test.dart +++ b/test/visual/chat_contacts_entry_test.dart @@ -26,6 +26,7 @@ Message buildMessage(List? payload) => Message( mediaReopened: false, isDeletedFromSender: false, isWidgetMedia: false, + isStory: false, createdAt: DateTime.now(), ); diff --git a/test/visual/chats/story_expiry_timer_test.dart b/test/visual/chats/story_expiry_timer_test.dart new file mode 100644 index 00000000..5c9b2e61 --- /dev/null +++ b/test/visual/chats/story_expiry_timer_test.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/database/daos/stories.dao.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart'; + +class _RecordedTimer implements Timer { + _RecordedTimer(this.duration, this.callback); + + final Duration duration; + final void Function() callback; + bool _active = true; + + @override + bool get isActive => _active; + + @override + int get tick => 0; + + @override + void cancel() => _active = false; + + void fire() { + if (!_active) return; + _active = false; + callback(); + } +} + +void main() { + test('an open story expires exactly at its lifetime boundary', () { + late _RecordedTimer scheduled; + var expired = false; + final timer = StoryExpiryTimer( + timerFactory: (duration, callback) => + scheduled = _RecordedTimer(duration, callback), + ); + final postedAt = DateTime(2026, 1, 1, 12); + + timer.schedule( + postedAt: postedAt, + now: postedAt, + onExpired: () => expired = true, + ); + expect(scheduled.duration, storyLifetime); + expect(expired, isFalse); + scheduled.fire(); + expect(expired, isTrue); + }); + + test('rescheduling cancels the previous story expiry', () { + final scheduled = <_RecordedTimer>[]; + var expirations = 0; + final timer = StoryExpiryTimer( + timerFactory: (duration, callback) { + final recorded = _RecordedTimer(duration, callback); + scheduled.add(recorded); + return recorded; + }, + ); + final now = DateTime(2026, 1, 1, 12); + + timer + ..schedule( + postedAt: now.subtract(storyLifetime - const Duration(seconds: 1)), + now: now, + onExpired: () => expirations++, + ) + ..schedule( + postedAt: now, + now: now, + onExpired: () => expirations++, + ); + expect(scheduled.first.isActive, isFalse); + scheduled.first.fire(); + expect(expirations, 0); + scheduled.last.fire(); + expect(expirations, 1); + }); +} diff --git a/test/visual/components/contact_groups_test.dart b/test/visual/components/contact_groups_test.dart index 6148028b..5f2e99dc 100644 --- a/test/visual/components/contact_groups_test.dart +++ b/test/visual/components/contact_groups_test.dart @@ -14,6 +14,7 @@ void main() { backgroundColor: 0x00123456, showAsShortcut: true, showAsLabel: true, + shareStories: false, usageCounter: 0, createdAt: DateTime(2026), ); diff --git a/test/visual/components/story_preview_test.dart b/test/visual/components/story_preview_test.dart new file mode 100644 index 00000000..d184b830 --- /dev/null +++ b/test/visual/components/story_preview_test.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart' show FaIcon; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/visual/components/story_preview.comp.dart'; + +void main() { + late TwonlyDB database; + late Directory support; + + setUp(() async { + support = Directory.systemTemp.createTempSync('story_preview_test'); + AppEnvironment.initTesting(customSupportDir: support.path); + database = TwonlyDB(NativeDatabase.memory()); + await database.customStatement( + "INSERT INTO media_files(media_id, type) VALUES ('img', 'image'), ('vid', 'video')", + ); + }); + + tearDown(() async { + await database.close(); + support.deleteSync(recursive: true); + }); + + Future media(String id) async => + (await database.mediaFilesDao.getMediaFileById(id))!; + + /// The file behind the preview, past the downscaling it is loaded with. + String shownFile(WidgetTester tester) { + final image = tester.widget(find.byType(Image)).image; + final resized = image as ResizeImage; + return (resized.imageProvider as FileImage).file.path; + } + + Future pump(WidgetTester tester, MediaFile file, {Widget? badge}) => + tester.pumpWidget( + MaterialApp( + home: Center( + child: StoryPreview( + mediaFile: file, + width: 40, + height: 40, + badge: badge, + ), + ), + ), + ); + + testWidgets('an image story is previewed from its own file', ( + tester, + ) async { + final image = await tester.runAsync(() => media('img')); + await pump(tester, image!); + expect(shownFile(tester), MediaFileService(image).tempPath.path); + }); + + testWidgets('a video story is previewed from its frame', (tester) async { + final video = await tester.runAsync(() => media('vid')); + await pump(tester, video!); + expect(shownFile(tester), MediaFileService(video).thumbnailPath.path); + }); + + testWidgets('a story not downloaded yet shows a tile with its owner', ( + tester, + ) async { + // The file is not there, so loading it fails and the tile stands in. + // Reading a file is real I/O, which only completes outside fake time. + await tester.runAsync(() async { + final image = await media('img'); + await pump(tester, image, badge: const Text('owner')); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pump(); + expect(find.byType(FaIcon), findsOneWidget); + expect(find.text('owner'), findsOneWidget); + }); +} diff --git a/test/visual/contact_group_shortcut_row_test.dart b/test/visual/contact_group_shortcut_row_test.dart new file mode 100644 index 00000000..7ddabaa0 --- /dev/null +++ b/test/visual/contact_group_shortcut_row_test.dart @@ -0,0 +1,187 @@ +import 'dart:collection'; + +import 'package:drift/drift.dart' hide isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/localization/generated/app_localizations.dart'; +import 'package:twonly/src/providers/settings.provider.dart'; +import 'package:twonly/src/visual/elements/my_chip.element.dart'; +import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart'; +import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/first_seen_order.dart'; + +class _LightSettings extends SettingsChangeProvider { + @override + ThemeMode get themeMode => ThemeMode.light; +} + +ContactGroup contactGroup(int id) => ContactGroup( + id: id, + name: 'group $id', + textColor: 0, + backgroundColor: 0, + showAsShortcut: true, + showAsLabel: true, + shareStories: false, + usageCounter: 0, + createdAt: DateTime(2026), +); + +void main() { + group('inFirstSeenOrder', () { + test('keeps the first order, drops what is gone, appends what is new', () { + final seen = []; + List ids(List groups) => + inFirstSeenOrder(groups, seen).map((g) => g.id).toList(); + + expect(ids([contactGroup(1), contactGroup(2), contactGroup(3)]), [ + 1, + 2, + 3, + ]); + // Group 3 got used the most and now arrives first. + expect(ids([contactGroup(3), contactGroup(1), contactGroup(2)]), [ + 1, + 2, + 3, + ]); + expect(ids([contactGroup(4), contactGroup(3), contactGroup(1)]), [ + 1, + 3, + 4, + ]); + }); + }); + + group('ContactGroupShortcutRow', () { + late TwonlyDB database; + + setUp(() async { + await locator.reset(); + database = TwonlyDB.forTesting( + DatabaseConnection( + NativeDatabase.memory(), + closeStreamsSynchronously: true, + ), + ); + locator.registerSingleton(database); + await database.customStatement( + "INSERT INTO groups(group_id, group_name) VALUES ('a1', 'A1'), " + "('a2', 'A2'), ('b1', 'B1')", + ); + // A is used more, so it comes first. + await database.customStatement( + 'INSERT INTO contact_groups(id, name, emoji, text_color, ' + 'background_color, show_as_shortcut, usage_counter) VALUES ' + "(1, 'A', '🅰', 0, 0, 1, 1), (2, 'B', '🅱', 0, 0, 1, 0)", + ); + await database.customStatement( + 'INSERT INTO contact_group_members(contact_group_id, group_id) VALUES ' + "(1, 'a1'), (1, 'a2'), (2, 'b1')", + ); + }); + + tearDown(() => database.close()); + + Future> pump( + WidgetTester tester, { + Set selected = const {}, + }) async { + final selectedGroupIds = HashSet.of(selected); + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => _LightSettings(), + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => ContactGroupShortcutRow( + selectedGroupIds: selectedGroupIds, + updateSelectedGroupIds: (groupId, checked) => setState(() { + if (checked) { + selectedGroupIds.add(groupId); + } else { + selectedGroupIds.remove(groupId); + } + }), + ), + ), + ), + ), + ), + ); + await settle(tester); + return selectedGroupIds; + } + + MyChip chip(WidgetTester tester, String label) => tester.widget( + find.ancestor(of: find.text(label), matching: find.byType(MyChip)), + ); + + testWidgets('a shortcut is on only while all its chats are selected', ( + tester, + ) async { + await pump(tester, selected: {'a1'}); + expect(chip(tester, '🅰').selected, isFalse); + + await pump(tester, selected: {'a1', 'a2', 'b1'}); + expect(chip(tester, '🅰').selected, isTrue); + expect(chip(tester, '🅱').selected, isTrue); + }); + + testWidgets('tapping selects its chats, tapping again clears them', ( + tester, + ) async { + final selected = await pump(tester, selected: {'b1'}); + + await tester.tap(find.text('🅰')); + await settle(tester); + expect(selected, {'a1', 'a2'}); + expect(chip(tester, '🅰').selected, isTrue); + + await tester.tap(find.text('🅰')); + await settle(tester); + expect(selected, isEmpty); + expect(chip(tester, '🅰').selected, isFalse); + }); + + testWidgets('a chip stays in place when using it makes it the most used', ( + tester, + ) async { + await pump(tester); + double left(String label) => tester.getTopLeft(find.text(label)).dx; + expect(left('🅰'), lessThan(left('🅱'))); + + await tester.tap(find.text('🅱')); + await settle(tester); + await tester.tap(find.text('🅱')); + await settle(tester); + await tester.tap(find.text('🅱')); + await settle(tester); + + final counters = await tester.runAsync( + () => database + .customSelect( + 'SELECT usage_counter FROM contact_groups WHERE id = 2', + ) + .getSingle(), + ); + expect(counters!.read('usage_counter'), 2); + expect(left('🅰'), lessThan(left('🅱'))); + }); + }); +} + +/// Lets the database answer, which only happens outside fake time. +Future settle(WidgetTester tester) async { + for (var i = 0; i < 3; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(const Duration(milliseconds: 500)); + } +} diff --git a/test/visual/elements/my_chip_element_test.dart b/test/visual/elements/my_chip_element_test.dart new file mode 100644 index 00000000..c2daf829 --- /dev/null +++ b/test/visual/elements/my_chip_element_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:twonly/src/providers/settings.provider.dart'; +import 'package:twonly/src/visual/elements/my_chip.element.dart'; + +class _LightSettings extends SettingsChangeProvider { + @override + ThemeMode get themeMode => ThemeMode.light; +} + +void main() { + Future pump(WidgetTester tester, Widget chip) => tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => _LightSettings(), + child: MaterialApp( + theme: ThemeData( + colorScheme: const ColorScheme.light(primary: Colors.teal), + ), + home: Scaffold(body: Center(child: chip)), + ), + ), + ); + + Color background(WidgetTester tester) { + final box = tester.widget( + find.byType(AnimatedContainer), + ); + return (box.decoration! as BoxDecoration).color!; + } + + testWidgets('a selected chip is filled, not ticked and not outlined', ( + tester, + ) async { + await pump( + tester, + MyChip(label: const Text('All contacts'), selected: true, onTap: () {}), + ); + await tester.pumpAndSettle(); + expect(background(tester), Colors.teal); + expect(find.byIcon(Icons.check), findsNothing); + final box = tester.widget( + find.byType(AnimatedContainer), + ); + expect((box.decoration! as BoxDecoration).border, isNull); + }); + + testWidgets('an unselected chip stays neutral', (tester) async { + await pump(tester, MyChip(label: const Text('Family'), onTap: () {})); + expect(background(tester), isNot(Colors.teal)); + }); + + testWidgets('a tap and a long press reach their callbacks', (tester) async { + var taps = 0; + var longPresses = 0; + await pump( + tester, + MyChip( + label: const Text('Family'), + onTap: () => taps++, + onLongPress: () => longPresses++, + ), + ); + await tester.tap(find.text('Family')); + await tester.longPress(find.text('Family')); + await tester.pumpAndSettle(); + expect(taps, 1); + expect(longPresses, 1); + }); +} diff --git a/test/visual/share_image_send_eligibility_test.dart b/test/visual/share_image_send_eligibility_test.dart new file mode 100644 index 00000000..e65f887d --- /dev/null +++ b/test/visual/share_image_send_eligibility_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/visual/views/camera/share_image_contact_selection.view.dart'; + +void main() { + test('story-only send waits for a non-empty resolved audience', () { + bool canSend(int? audienceSize) => canSendSharedMedia( + mediaReady: true, + sending: false, + hasChatRecipients: false, + storyPossible: true, + hasStoryTarget: true, + storyAudienceSize: audienceSize, + ); + + expect(canSend(null), isFalse); + expect(canSend(0), isFalse); + expect(canSend(1), isTrue); + }); + + test('ordinary recipients can send independently of a story audience', () { + expect( + canSendSharedMedia( + mediaReady: true, + sending: false, + hasChatRecipients: true, + storyPossible: true, + hasStoryTarget: true, + storyAudienceSize: 0, + ), + isTrue, + ); + }); + + test('send remains disabled while media is pending or already sending', () { + for (final state in [(false, false), (true, true)]) { + expect( + canSendSharedMedia( + mediaReady: state.$1, + sending: state.$2, + hasChatRecipients: true, + storyPossible: false, + hasStoryTarget: false, + storyAudienceSize: null, + ), + isFalse, + ); + } + }); + + test('the send button counts story recipients, each chat once', () { + int count(Set selected, Set? story) => + sharedMediaRecipientCount( + selectedGroupIds: selected, + storyAudienceChats: story, + ); + + expect(count({'family'}, null), 1); + expect(count({}, {'anna', 'ben'}), 2); + // Anna gets the snap directly and the story, but is one recipient. + expect(count({'family', 'anna'}, {'anna', 'ben'}), 3); + }); +}