mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
add stories feature
This commit is contained in:
parent
856a67edaa
commit
5ba1a9db88
116 changed files with 7361 additions and 801 deletions
|
|
@ -4,6 +4,8 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.usernotifications.communication</key>
|
<key>com.apple.developer.usernotifications.communication</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.developer.usernotifications.filtering</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.eu.twonly.runtime</string>
|
<string>group.eu.twonly.runtime</string>
|
||||||
|
|
|
||||||
|
|
@ -107,12 +107,16 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
guard let batch = response.batch, !batch.additions.isEmpty else {
|
guard let batch = response.batch, !batch.additions.isEmpty else {
|
||||||
// Nothing is waiting on screen for this wake-up — widget-only media is
|
// Nothing is waiting on screen for this wake-up — widget-only media, a
|
||||||
// the ordinary case — so the deferred work runs while the extension is
|
// story the user muted or one that already expired — so the deferred
|
||||||
// still guaranteed its time, rather than after the content handler has
|
// work runs while the extension is still guaranteed its time, rather
|
||||||
// made it eligible for termination.
|
// than after the content handler has made it eligible for termination.
|
||||||
Self.finalizeRuntime()
|
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
|
return
|
||||||
}
|
}
|
||||||
self.render(batch: batch, original: request.content)
|
self.render(batch: batch, original: request.content)
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,6 @@ PODS:
|
||||||
- nanopb/encode (= 3.30910.0)
|
- nanopb/encode (= 3.30910.0)
|
||||||
- nanopb/decode (3.30910.0)
|
- nanopb/decode (3.30910.0)
|
||||||
- nanopb/encode (3.30910.0)
|
- nanopb/encode (3.30910.0)
|
||||||
- permission_handler_apple (9.3.0):
|
|
||||||
- Flutter
|
|
||||||
- PromisesObjC (2.4.0)
|
- PromisesObjC (2.4.0)
|
||||||
- rust_lib_twonly (0.0.1)
|
- rust_lib_twonly (0.0.1)
|
||||||
- screen_protector (1.5.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_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`)
|
||||||
- google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`)
|
- google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`)
|
||||||
- google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/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`)
|
- rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`)
|
||||||
- screen_protector (from `.symlinks/plugins/screen_protector/ios`)
|
- screen_protector (from `.symlinks/plugins/screen_protector/ios`)
|
||||||
- SwiftProtobuf
|
- SwiftProtobuf
|
||||||
|
|
@ -128,8 +125,6 @@ EXTERNAL SOURCES:
|
||||||
:path: ".symlinks/plugins/google_mlkit_commons/ios"
|
:path: ".symlinks/plugins/google_mlkit_commons/ios"
|
||||||
google_mlkit_face_detection:
|
google_mlkit_face_detection:
|
||||||
:path: ".symlinks/plugins/google_mlkit_face_detection/ios"
|
:path: ".symlinks/plugins/google_mlkit_face_detection/ios"
|
||||||
permission_handler_apple:
|
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
|
||||||
rust_lib_twonly:
|
rust_lib_twonly:
|
||||||
:path: ".symlinks/plugins/rust_lib_twonly/ios"
|
:path: ".symlinks/plugins/rust_lib_twonly/ios"
|
||||||
screen_protector:
|
screen_protector:
|
||||||
|
|
@ -155,7 +150,6 @@ SPEC CHECKSUMS:
|
||||||
MLKitFaceDetection: 32549f1e70e6e7731261bf9cea2b74095e2531cb
|
MLKitFaceDetection: 32549f1e70e6e7731261bf9cea2b74095e2531cb
|
||||||
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
|
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
rust_lib_twonly: 6586fdf02e31cd8a3ad9f1ee84796da6bbf289bb
|
rust_lib_twonly: 6586fdf02e31cd8a3ad9f1ee84796da6bbf289bb
|
||||||
screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150
|
screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/app-check.git",
|
"location" : "https://github.com/google/app-check.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
|
"revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a",
|
||||||
"version" : "11.3.1"
|
"version" : "11.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_sharing_intent/model/sharing_file.dart' show SharedFile;
|
import 'package:flutter_sharing_intent/model/sharing_file.dart' show SharedFile;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:twonly/core/bridge/api.dart' as rust_api;
|
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/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
||||||
|
|
@ -69,6 +70,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||||
// was suspended, and its Rust change broadcast never reached this
|
// was suspended, and its Rust change broadcast never reached this
|
||||||
// process, so the badge has to be re-read on the way back in.
|
// process, so the badge has to be re-read on the way back in.
|
||||||
unawaited(NativeNotificationService.refreshBadgeCount());
|
unawaited(NativeNotificationService.refreshBadgeCount());
|
||||||
|
// A story may have expired while the app was away.
|
||||||
|
unawaitedRustCall(stories.purgeExpiredStories(), 'purgeExpiredStories');
|
||||||
} else if (state == AppLifecycleState.paused) {
|
} else if (state == AppLifecycleState.paused) {
|
||||||
AppState.isAppInBackground = true;
|
AppState.isAppInBackground = true;
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../api/server/prekeys.dart';
|
||||||
import '../frb_generated.dart';
|
import '../frb_generated.dart';
|
||||||
import '../services/media_upload.dart';
|
import '../services/media_upload.dart';
|
||||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.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 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`
|
// 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,
|
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<void> sendMediaToGroups({
|
static Future<void> sendMediaToGroups({
|
||||||
required String mediaId,
|
required String mediaId,
|
||||||
required List<String> groupIds,
|
required List<String> groupIds,
|
||||||
Uint8List? additionalMessageData,
|
Uint8List? additionalMessageData,
|
||||||
required bool widgetOnly,
|
required bool widgetOnly,
|
||||||
|
StoryAudience? storyAudience,
|
||||||
}) => RustLib.instance.api.crateBridgeApiRustApiSendMediaToGroups(
|
}) => RustLib.instance.api.crateBridgeApiRustApiSendMediaToGroups(
|
||||||
mediaId: mediaId,
|
mediaId: mediaId,
|
||||||
groupIds: groupIds,
|
groupIds: groupIds,
|
||||||
additionalMessageData: additionalMessageData,
|
additionalMessageData: additionalMessageData,
|
||||||
widgetOnly: widgetOnly,
|
widgetOnly: widgetOnly,
|
||||||
|
storyAudience: storyAudience,
|
||||||
);
|
);
|
||||||
|
|
||||||
static Future<void> sendQueuedMessage({required String receiptId}) => RustLib
|
static Future<void> sendQueuedMessage({required String receiptId}) => RustLib
|
||||||
|
|
|
||||||
48
lib/core/bridge/stories.dart
Normal file
48
lib/core/bridge/stories.dart
Normal file
|
|
@ -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<List<String>> 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<void> deleteStoryItem({required String mediaId}) =>
|
||||||
|
RustLib.instance.api.crateBridgeStoriesDeleteStoryItem(mediaId: mediaId);
|
||||||
|
|
||||||
|
/// Lets go of every story item older than 24 hours.
|
||||||
|
Future<void> 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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@ import 'bridge/api.dart';
|
||||||
import 'bridge/callbacks.dart';
|
import 'bridge/callbacks.dart';
|
||||||
import 'bridge/groups.dart';
|
import 'bridge/groups.dart';
|
||||||
import 'bridge/logging.dart';
|
import 'bridge/logging.dart';
|
||||||
|
import 'bridge/stories.dart';
|
||||||
import 'bridge/user_config.dart';
|
import 'bridge/user_config.dart';
|
||||||
import 'bridge/webxdc.dart';
|
import 'bridge/webxdc.dart';
|
||||||
import 'bridge/wrapper.dart';
|
import 'bridge/wrapper.dart';
|
||||||
|
|
@ -120,6 +121,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
PasswordlessRecoveryConfig
|
PasswordlessRecoveryConfig
|
||||||
dco_decode_box_autoadd_passwordless_recovery_config(dynamic raw);
|
dco_decode_box_autoadd_passwordless_recovery_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience dco_decode_box_autoadd_story_audience(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup dco_decode_box_autoadd_twonly_safe_backup(dynamic raw);
|
TwonlySafeBackup dco_decode_box_autoadd_twonly_safe_backup(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -298,6 +302,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
PasswordlessRecoveryConfig?
|
PasswordlessRecoveryConfig?
|
||||||
dco_decode_opt_box_autoadd_passwordless_recovery_config(dynamic raw);
|
dco_decode_opt_box_autoadd_passwordless_recovery_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience? dco_decode_opt_box_autoadd_story_audience(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup? dco_decode_opt_box_autoadd_twonly_safe_backup(dynamic raw);
|
TwonlySafeBackup? dco_decode_opt_box_autoadd_twonly_safe_backup(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -376,6 +383,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
SqlValue dco_decode_sql_value(dynamic raw);
|
SqlValue dco_decode_sql_value(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience dco_decode_story_audience(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
ThemeMode dco_decode_theme_mode(dynamic raw);
|
ThemeMode dco_decode_theme_mode(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -521,6 +531,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience sse_decode_box_autoadd_story_audience(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup sse_decode_box_autoadd_twonly_safe_backup(
|
TwonlySafeBackup sse_decode_box_autoadd_twonly_safe_backup(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
|
|
@ -733,6 +748,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience? sse_decode_opt_box_autoadd_story_audience(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup? sse_decode_opt_box_autoadd_twonly_safe_backup(
|
TwonlySafeBackup? sse_decode_opt_box_autoadd_twonly_safe_backup(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
|
|
@ -827,6 +847,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
|
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience sse_decode_story_audience(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
ThemeMode sse_decode_theme_mode(SseDeserializer deserializer);
|
ThemeMode sse_decode_theme_mode(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -1013,6 +1036,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_story_audience(
|
||||||
|
StoryAudience self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_twonly_safe_backup(
|
void sse_encode_box_autoadd_twonly_safe_backup(
|
||||||
TwonlySafeBackup self,
|
TwonlySafeBackup self,
|
||||||
|
|
@ -1280,6 +1309,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_story_audience(
|
||||||
|
StoryAudience? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_twonly_safe_backup(
|
void sse_encode_opt_box_autoadd_twonly_safe_backup(
|
||||||
TwonlySafeBackup? self,
|
TwonlySafeBackup? self,
|
||||||
|
|
@ -1397,6 +1432,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
|
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_story_audience(StoryAudience self, SseSerializer serializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_theme_mode(ThemeMode self, SseSerializer serializer);
|
void sse_encode_theme_mode(ThemeMode self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'bridge/api.dart';
|
||||||
import 'bridge/callbacks.dart';
|
import 'bridge/callbacks.dart';
|
||||||
import 'bridge/groups.dart';
|
import 'bridge/groups.dart';
|
||||||
import 'bridge/logging.dart';
|
import 'bridge/logging.dart';
|
||||||
|
import 'bridge/stories.dart';
|
||||||
import 'bridge/user_config.dart';
|
import 'bridge/user_config.dart';
|
||||||
import 'bridge/webxdc.dart';
|
import 'bridge/webxdc.dart';
|
||||||
import 'bridge/wrapper.dart';
|
import 'bridge/wrapper.dart';
|
||||||
|
|
@ -122,6 +123,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
PasswordlessRecoveryConfig
|
PasswordlessRecoveryConfig
|
||||||
dco_decode_box_autoadd_passwordless_recovery_config(dynamic raw);
|
dco_decode_box_autoadd_passwordless_recovery_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience dco_decode_box_autoadd_story_audience(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup dco_decode_box_autoadd_twonly_safe_backup(dynamic raw);
|
TwonlySafeBackup dco_decode_box_autoadd_twonly_safe_backup(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -300,6 +304,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
PasswordlessRecoveryConfig?
|
PasswordlessRecoveryConfig?
|
||||||
dco_decode_opt_box_autoadd_passwordless_recovery_config(dynamic raw);
|
dco_decode_opt_box_autoadd_passwordless_recovery_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience? dco_decode_opt_box_autoadd_story_audience(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup? dco_decode_opt_box_autoadd_twonly_safe_backup(dynamic raw);
|
TwonlySafeBackup? dco_decode_opt_box_autoadd_twonly_safe_backup(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -378,6 +385,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
SqlValue dco_decode_sql_value(dynamic raw);
|
SqlValue dco_decode_sql_value(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience dco_decode_story_audience(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
ThemeMode dco_decode_theme_mode(dynamic raw);
|
ThemeMode dco_decode_theme_mode(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -523,6 +533,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience sse_decode_box_autoadd_story_audience(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup sse_decode_box_autoadd_twonly_safe_backup(
|
TwonlySafeBackup sse_decode_box_autoadd_twonly_safe_backup(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
|
|
@ -735,6 +750,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience? sse_decode_opt_box_autoadd_story_audience(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup? sse_decode_opt_box_autoadd_twonly_safe_backup(
|
TwonlySafeBackup? sse_decode_opt_box_autoadd_twonly_safe_backup(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
|
|
@ -829,6 +849,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
|
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
StoryAudience sse_decode_story_audience(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
ThemeMode sse_decode_theme_mode(SseDeserializer deserializer);
|
ThemeMode sse_decode_theme_mode(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -1015,6 +1038,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_story_audience(
|
||||||
|
StoryAudience self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_twonly_safe_backup(
|
void sse_encode_box_autoadd_twonly_safe_backup(
|
||||||
TwonlySafeBackup self,
|
TwonlySafeBackup self,
|
||||||
|
|
@ -1282,6 +1311,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_story_audience(
|
||||||
|
StoryAudience? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_twonly_safe_backup(
|
void sse_encode_opt_box_autoadd_twonly_safe_backup(
|
||||||
TwonlySafeBackup? self,
|
TwonlySafeBackup? self,
|
||||||
|
|
@ -1399,6 +1434,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
|
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_story_audience(StoryAudience self, SseSerializer serializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_theme_mode(ThemeMode self, SseSerializer serializer);
|
void sse_encode_theme_mode(ThemeMode self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,10 @@ class UserConfig {
|
||||||
bool storeLocationInMemories;
|
bool storeLocationInMemories;
|
||||||
bool autoStoreAllSendUnlimitedMediaFiles;
|
bool autoStoreAllSendUnlimitedMediaFiles;
|
||||||
bool typingIndicators;
|
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;
|
bool showRestoreFlame;
|
||||||
|
|
||||||
/// Unused: the best friend is derived from `total_media_counter` whenever
|
/// Unused: the best friend is derived from `total_media_counter` whenever
|
||||||
|
|
@ -217,6 +221,7 @@ class UserConfig {
|
||||||
required this.storeLocationInMemories,
|
required this.storeLocationInMemories,
|
||||||
required this.autoStoreAllSendUnlimitedMediaFiles,
|
required this.autoStoreAllSendUnlimitedMediaFiles,
|
||||||
required this.typingIndicators,
|
required this.typingIndicators,
|
||||||
|
required this.storyNotifications,
|
||||||
required this.showRestoreFlame,
|
required this.showRestoreFlame,
|
||||||
this.myBestFriendGroupId,
|
this.myBestFriendGroupId,
|
||||||
this.signalLastSignedPreKeyUpdated,
|
this.signalLastSignedPreKeyUpdated,
|
||||||
|
|
@ -284,6 +289,7 @@ class UserConfig {
|
||||||
storeLocationInMemories.hashCode ^
|
storeLocationInMemories.hashCode ^
|
||||||
autoStoreAllSendUnlimitedMediaFiles.hashCode ^
|
autoStoreAllSendUnlimitedMediaFiles.hashCode ^
|
||||||
typingIndicators.hashCode ^
|
typingIndicators.hashCode ^
|
||||||
|
storyNotifications.hashCode ^
|
||||||
showRestoreFlame.hashCode ^
|
showRestoreFlame.hashCode ^
|
||||||
myBestFriendGroupId.hashCode ^
|
myBestFriendGroupId.hashCode ^
|
||||||
signalLastSignedPreKeyUpdated.hashCode ^
|
signalLastSignedPreKeyUpdated.hashCode ^
|
||||||
|
|
@ -356,6 +362,7 @@ class UserConfig {
|
||||||
autoStoreAllSendUnlimitedMediaFiles ==
|
autoStoreAllSendUnlimitedMediaFiles ==
|
||||||
other.autoStoreAllSendUnlimitedMediaFiles &&
|
other.autoStoreAllSendUnlimitedMediaFiles &&
|
||||||
typingIndicators == other.typingIndicators &&
|
typingIndicators == other.typingIndicators &&
|
||||||
|
storyNotifications == other.storyNotifications &&
|
||||||
showRestoreFlame == other.showRestoreFlame &&
|
showRestoreFlame == other.showRestoreFlame &&
|
||||||
myBestFriendGroupId == other.myBestFriendGroupId &&
|
myBestFriendGroupId == other.myBestFriendGroupId &&
|
||||||
signalLastSignedPreKeyUpdated ==
|
signalLastSignedPreKeyUpdated ==
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
import 'package:twonly/app.dart';
|
import 'package:twonly/app.dart';
|
||||||
import 'package:twonly/core/bridge.dart' as bridge;
|
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/app_database.dart';
|
||||||
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
||||||
import 'package:twonly/core/frb_generated.dart';
|
import 'package:twonly/core/frb_generated.dart';
|
||||||
|
|
@ -290,6 +291,7 @@ Future<void> postStartupTasks() async {
|
||||||
unawaited(MemoriesService.prewarmCache());
|
unawaited(MemoriesService.prewarmCache());
|
||||||
|
|
||||||
// 1. Immediate background cleanup (Non-blocking for UI)
|
// 1. Immediate background cleanup (Non-blocking for UI)
|
||||||
|
unawaitedRustCall(stories.purgeExpiredStories(), 'purgeExpiredStories');
|
||||||
await twonlyDB.messagesDao.purgeMessageTable();
|
await twonlyDB.messagesDao.purgeMessageTable();
|
||||||
unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts());
|
unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts());
|
||||||
unawaited(MediaFileService.purgeTempFolder());
|
unawaited(MediaFileService.purgeTempFolder());
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ class Routes {
|
||||||
|
|
||||||
static String chatsMessages(String groupId) => '/chats/messages/$groupId';
|
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) =>
|
static String groupCreateSelectMember(String? groupId) =>
|
||||||
'/group/create/select_member${groupId == null ? '' : '/$groupId'}';
|
'/group/create/select_member${groupId == null ? '' : '/$groupId'}';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,17 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
||||||
.watch();
|
.watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The contact groups a story can be sent to, most used first.
|
||||||
|
Stream<List<ContactGroup>> watchStoryContactGroups() {
|
||||||
|
return (select(contactGroups)
|
||||||
|
..where((t) => t.shareStories.equals(true))
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.desc(t.usageCounter),
|
||||||
|
(t) => OrderingTerm.asc(t.name),
|
||||||
|
]))
|
||||||
|
.watch();
|
||||||
|
}
|
||||||
|
|
||||||
Stream<List<ContactGroup>> watchVisibleGroupsForUser(int userId) {
|
Stream<List<ContactGroup>> watchVisibleGroupsForUser(int userId) {
|
||||||
final query =
|
final query =
|
||||||
select(contactGroupMembers).join([
|
select(contactGroupMembers).join([
|
||||||
|
|
@ -132,6 +143,7 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
||||||
required int backgroundColor,
|
required int backgroundColor,
|
||||||
required bool showAsShortcut,
|
required bool showAsShortcut,
|
||||||
required bool showAsLabel,
|
required bool showAsLabel,
|
||||||
|
required bool shareStories,
|
||||||
String? emoji,
|
String? emoji,
|
||||||
}) {
|
}) {
|
||||||
return into(contactGroups).insert(
|
return into(contactGroups).insert(
|
||||||
|
|
@ -142,6 +154,7 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
showAsShortcut: Value(showAsShortcut),
|
showAsShortcut: Value(showAsShortcut),
|
||||||
showAsLabel: Value(showAsLabel),
|
showAsLabel: Value(showAsLabel),
|
||||||
|
shareStories: Value(shareStories),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -153,6 +166,7 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
||||||
required int backgroundColor,
|
required int backgroundColor,
|
||||||
required bool showAsShortcut,
|
required bool showAsShortcut,
|
||||||
required bool showAsLabel,
|
required bool showAsLabel,
|
||||||
|
required bool shareStories,
|
||||||
String? emoji,
|
String? emoji,
|
||||||
}) {
|
}) {
|
||||||
return (update(contactGroups)..where((t) => t.id.equals(id)))
|
return (update(contactGroups)..where((t) => t.id.equals(id)))
|
||||||
|
|
@ -164,6 +178,7 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
||||||
backgroundColor: Value(backgroundColor),
|
backgroundColor: Value(backgroundColor),
|
||||||
showAsShortcut: Value(showAsShortcut),
|
showAsShortcut: Value(showAsShortcut),
|
||||||
showAsLabel: Value(showAsLabel),
|
showAsLabel: Value(showAsLabel),
|
||||||
|
shareStories: Value(shareStories),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then((rows) => rows > 0);
|
.then((rows) => rows > 0);
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,11 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
db.messages.mediaId.equalsExp(mediaFiles.mediaId),
|
db.messages.mediaId.equalsExp(mediaFiles.mediaId),
|
||||||
useColumns: false,
|
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();
|
return query.map((row) => row.readTable(mediaFiles)).watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,6 +230,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
) AS message_rank
|
) AS message_rank
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE messages.media_id IS NOT NULL
|
WHERE messages.media_id IS NOT NULL
|
||||||
|
AND (messages.is_story = 0 OR messages.media_stored = 1)
|
||||||
)
|
)
|
||||||
SELECT DISTINCT media_files.*
|
SELECT DISTINCT media_files.*
|
||||||
FROM media_files
|
FROM media_files
|
||||||
|
|
@ -254,6 +259,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
mediaFiles.storedFileHash.equals(hash) &
|
mediaFiles.storedFileHash.equals(hash) &
|
||||||
db.messages.senderId.equals(senderId) &
|
db.messages.senderId.equals(senderId) &
|
||||||
db.messages.isWidgetMedia.equals(false) &
|
db.messages.isWidgetMedia.equals(false) &
|
||||||
|
db.messages.isStory.equals(false) &
|
||||||
db.messages.openedAt.isNull(),
|
db.messages.openedAt.isNull(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
// ignore: matching_super_parameters
|
// ignore: matching_super_parameters
|
||||||
MessagesDao(super.db);
|
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<bool> get _visibleInChat =>
|
||||||
|
messages.isStory.equals(false) | messages.mediaStored.equals(true);
|
||||||
|
|
||||||
Stream<List<Message>> watchMessageNotOpened(String groupId) {
|
Stream<List<Message>> watchMessageNotOpened(String groupId) {
|
||||||
final query =
|
final query =
|
||||||
select(messages).join([
|
select(messages).join([
|
||||||
|
|
@ -42,6 +47,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
..where(
|
..where(
|
||||||
messages.openedAt.isNull() &
|
messages.openedAt.isNull() &
|
||||||
messages.isWidgetMedia.equals(false) &
|
messages.isWidgetMedia.equals(false) &
|
||||||
|
_visibleInChat &
|
||||||
messages.groupId.equals(groupId) &
|
messages.groupId.equals(groupId) &
|
||||||
messages.isDeletedFromSender.equals(false) &
|
messages.isDeletedFromSender.equals(false) &
|
||||||
(messages.mediaId.isNull() |
|
(messages.mediaId.isNull() |
|
||||||
|
|
@ -65,6 +71,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
..where(
|
..where(
|
||||||
messages.openedAt.isNull() &
|
messages.openedAt.isNull() &
|
||||||
messages.isWidgetMedia.equals(false) &
|
messages.isWidgetMedia.equals(false) &
|
||||||
|
_visibleInChat &
|
||||||
messages.isDeletedFromSender.equals(false) &
|
messages.isDeletedFromSender.equals(false) &
|
||||||
(messages.mediaId.isNull() |
|
(messages.mediaId.isNull() |
|
||||||
mediaFiles.downloadState.isNull() |
|
mediaFiles.downloadState.isNull() |
|
||||||
|
|
@ -92,6 +99,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
mediaFiles.type.equals(MediaType.audio.name).not() &
|
mediaFiles.type.equals(MediaType.audio.name).not() &
|
||||||
messages.openedAt.isNull() &
|
messages.openedAt.isNull() &
|
||||||
messages.isWidgetMedia.equals(false) &
|
messages.isWidgetMedia.equals(false) &
|
||||||
|
_visibleInChat &
|
||||||
messages.groupId.equals(groupId) &
|
messages.groupId.equals(groupId) &
|
||||||
messages.mediaId.isNotNull() &
|
messages.mediaId.isNotNull() &
|
||||||
messages.senderId.isNotNull() &
|
messages.senderId.isNotNull() &
|
||||||
|
|
@ -111,6 +119,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
])..where(
|
])..where(
|
||||||
messages.openedAt.isNull() &
|
messages.openedAt.isNull() &
|
||||||
messages.isWidgetMedia.equals(false) &
|
messages.isWidgetMedia.equals(false) &
|
||||||
|
_visibleInChat &
|
||||||
messages.mediaId.isNotNull() &
|
messages.mediaId.isNotNull() &
|
||||||
messages.type.equals(MessageType.media.name) &
|
messages.type.equals(MessageType.media.name) &
|
||||||
mediaFiles.downloadState.equals(DownloadState.ready.name) &
|
mediaFiles.downloadState.equals(DownloadState.ready.name) &
|
||||||
|
|
@ -136,6 +145,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
])
|
])
|
||||||
..where(
|
..where(
|
||||||
messages.groupId.equals(groupId) &
|
messages.groupId.equals(groupId) &
|
||||||
|
_visibleInChat &
|
||||||
// messages in groups will only be removed in case all members have received it...
|
// 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
|
// so ensuring that this message is not shown in the messages anymore
|
||||||
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
||||||
|
|
@ -169,7 +179,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
FROM messages
|
FROM messages
|
||||||
INNER JOIN groups ON groups.group_id = messages.group_id
|
INNER JOIN groups ON groups.group_id = messages.group_id
|
||||||
LEFT JOIN media_files ON media_files.media_id = messages.media_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.opened_at IS NULL OR
|
||||||
messages.media_stored = 1 OR
|
messages.media_stored = 1 OR
|
||||||
messages.opened_at > CAST(strftime('%s', 'now') AS INTEGER) -
|
messages.opened_at > CAST(strftime('%s', 'now') AS INTEGER) -
|
||||||
|
|
@ -207,6 +218,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
])
|
])
|
||||||
..where(
|
..where(
|
||||||
messages.groupId.equals(groupId) &
|
messages.groupId.equals(groupId) &
|
||||||
|
_visibleInChat &
|
||||||
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
||||||
messages.openedAt.isNull() |
|
messages.openedAt.isNull() |
|
||||||
messages.mediaStored.equals(true) |
|
messages.mediaStored.equals(true) |
|
||||||
|
|
@ -251,6 +263,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
])
|
])
|
||||||
..where(
|
..where(
|
||||||
messages.groupId.equals(groupId) &
|
messages.groupId.equals(groupId) &
|
||||||
|
_visibleInChat &
|
||||||
(messages.createdAt.isSmallerThanValue(before) |
|
(messages.createdAt.isSmallerThanValue(before) |
|
||||||
(messages.createdAt.equals(before) &
|
(messages.createdAt.equals(before) &
|
||||||
messages.messageId.isSmallerThanValue(
|
messages.messageId.isSmallerThanValue(
|
||||||
|
|
@ -319,6 +332,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
// without ever clearing the storage its origin holds, so
|
// without ever clearing the storage its origin holds, so
|
||||||
// apps leave only when somebody deletes them.
|
// apps leave only when somebody deletes them.
|
||||||
m.type.equals(MessageType.webxdcApp.name).not() &
|
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.mediaStored.equals(true) &
|
||||||
m.isDeletedFromSender.equals(true)) |
|
m.isDeletedFromSender.equals(true)) |
|
||||||
m.mediaStored.equals(false)) &
|
m.mediaStored.equals(false)) &
|
||||||
|
|
@ -645,11 +660,13 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
await transaction(() async {
|
await transaction(() async {
|
||||||
// A one-time app is the chat's durable shared object. Keep its card (and
|
// 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
|
// 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(
|
await customStatement(
|
||||||
'''
|
'''
|
||||||
DELETE FROM messages
|
DELETE FROM messages
|
||||||
WHERE group_id = ?
|
WHERE group_id = ?
|
||||||
|
AND (is_story = 0 OR media_stored = 1)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM webxdc_instances AS instance
|
FROM webxdc_instances AS instance
|
||||||
|
|
@ -671,6 +688,15 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> 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<List<Message>> watchMessagesByIds(Set<String> messageIds) {
|
||||||
|
if (messageIds.isEmpty) return Stream.value(const []);
|
||||||
|
return (select(
|
||||||
|
messages,
|
||||||
|
)..where((t) => t.messageId.isIn(messageIds))).watch();
|
||||||
|
}
|
||||||
|
|
||||||
SingleOrNullSelectable<Message> getMessageById(String messageId) {
|
SingleOrNullSelectable<Message> getMessageById(String messageId) {
|
||||||
return select(messages)..where((t) => t.messageId.equals(messageId));
|
return select(messages)..where((t) => t.messageId.equals(messageId));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
|
||||||
..orderBy([(reaction) => OrderingTerm.desc(reaction.createdAt)]))
|
..orderBy([(reaction) => OrderingTerm.desc(reaction.createdAt)]))
|
||||||
.watch();
|
.watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<Reaction?> watchLastReactions(String groupId) {
|
Stream<Reaction?> watchLastReactions(String groupId) {
|
||||||
final query =
|
final query =
|
||||||
(select(reactions)).join(
|
(select(reactions)).join(
|
||||||
|
|
@ -126,7 +127,11 @@ Stream<Reaction?> 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)])
|
..orderBy([OrderingTerm.desc(messages.createdAt)])
|
||||||
..limit(1);
|
..limit(1);
|
||||||
return query.map((row) => row.readTable(reactions)).watchSingleOrNull();
|
return query.map((row) => row.readTable(reactions)).watchSingleOrNull();
|
||||||
|
|
@ -145,6 +150,7 @@ Stream<Reaction?> watchLastReactions(String groupId) {
|
||||||
) AS reaction_rank
|
) AS reaction_rank
|
||||||
FROM reactions
|
FROM reactions
|
||||||
INNER JOIN messages ON messages.message_id = reactions.message_id
|
INNER JOIN messages ON messages.message_id = reactions.message_id
|
||||||
|
WHERE messages.is_story = 0 OR messages.media_stored = 1
|
||||||
) AS reaction_rows
|
) AS reaction_rows
|
||||||
WHERE reaction_rank = 1
|
WHERE reaction_rank = 1
|
||||||
''',
|
''',
|
||||||
|
|
|
||||||
203
lib/src/database/daos/stories.dao.dart
Normal file
203
lib/src/database/daos/stories.dao.dart
Normal file
|
|
@ -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<TwonlyDB> with _$StoriesDaoMixin {
|
||||||
|
// ignore: matching_super_parameters
|
||||||
|
StoriesDao(super.db);
|
||||||
|
|
||||||
|
JoinedSelectStatement<HasResultSet, dynamic> _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<List<StoryItem>> watchReceivedStoryItems() {
|
||||||
|
return _receivedItems().map(_readItem).watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<List<StoryItem>> watchReceivedStoryItemsForGroup(String groupId) {
|
||||||
|
return (_receivedItems()..where(messages.groupId.equals(groupId)))
|
||||||
|
.map(_readItem)
|
||||||
|
.watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<StoryItem>> 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<List<OwnStoryItem>> 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<DateTime>(clock.now().subtract(storyLifetime))],
|
||||||
|
readsFrom: {messages, mediaFiles},
|
||||||
|
).watch().map(
|
||||||
|
(rows) => rows
|
||||||
|
.map(
|
||||||
|
(row) => OwnStoryItem(
|
||||||
|
mediaFile: mediaFiles.map(row.data),
|
||||||
|
postedAt: row.read<DateTime>('posted_at'),
|
||||||
|
recipientCount: row.read<int>('recipient_count'),
|
||||||
|
viewerCount: row.read<int>('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<List<StoryViewer>> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
34
lib/src/database/daos/stories.dao.g.dart
Normal file
34
lib/src/database/daos/stories.dao.g.dart
Normal file
|
|
@ -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<TwonlyDB> {
|
||||||
|
$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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ class ContactGroups extends Table {
|
||||||
BoolColumn get showAsShortcut =>
|
BoolColumn get showAsShortcut =>
|
||||||
boolean().withDefault(const Constant(false))();
|
boolean().withDefault(const Constant(false))();
|
||||||
BoolColumn get showAsLabel => boolean().withDefault(const Constant(true))();
|
BoolColumn get showAsLabel => boolean().withDefault(const Constant(true))();
|
||||||
|
BoolColumn get shareStories => boolean().withDefault(const Constant(false))();
|
||||||
IntColumn get usageCounter => integer().withDefault(const Constant(0))();
|
IntColumn get usageCounter => integer().withDefault(const Constant(0))();
|
||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ class Messages extends Table {
|
||||||
BoolColumn get isWidgetMedia =>
|
BoolColumn get isWidgetMedia =>
|
||||||
boolean().withDefault(const Constant(false))();
|
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 openedAt => dateTime().nullable()();
|
||||||
DateTimeColumn get openedByAll => dateTime().nullable()();
|
DateTimeColumn get openedByAll => dateTime().nullable()();
|
||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
|
||||||
|
|
@ -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/messages.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/reactions.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/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/daos/user_discovery.dao.dart';
|
||||||
import 'package:twonly/src/database/rust_change_notifier.dart';
|
import 'package:twonly/src/database/rust_change_notifier.dart';
|
||||||
import 'package:twonly/src/database/rust_query_executor.dart';
|
import 'package:twonly/src/database/rust_query_executor.dart';
|
||||||
|
|
@ -63,6 +64,7 @@ part 'twonly.db.g.dart';
|
||||||
UserDiscoveryDao,
|
UserDiscoveryDao,
|
||||||
KeyVerificationDao,
|
KeyVerificationDao,
|
||||||
ContactGroupsDao,
|
ContactGroupsDao,
|
||||||
|
StoriesDao,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class TwonlyDB extends _$TwonlyDB {
|
class TwonlyDB extends _$TwonlyDB {
|
||||||
|
|
|
||||||
|
|
@ -5378,6 +5378,21 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
||||||
),
|
),
|
||||||
defaultValue: const Constant(false),
|
defaultValue: const Constant(false),
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _isStoryMeta = const VerificationMeta(
|
||||||
|
'isStory',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<bool> isStory = GeneratedColumn<bool>(
|
||||||
|
'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(
|
static const VerificationMeta _openedAtMeta = const VerificationMeta(
|
||||||
'openedAt',
|
'openedAt',
|
||||||
);
|
);
|
||||||
|
|
@ -5460,6 +5475,7 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
||||||
quotesMessageId,
|
quotesMessageId,
|
||||||
isDeletedFromSender,
|
isDeletedFromSender,
|
||||||
isWidgetMedia,
|
isWidgetMedia,
|
||||||
|
isStory,
|
||||||
openedAt,
|
openedAt,
|
||||||
openedByAll,
|
openedByAll,
|
||||||
createdAt,
|
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')) {
|
if (data.containsKey('opened_at')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_openedAtMeta,
|
_openedAtMeta,
|
||||||
|
|
@ -5687,6 +5709,10 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
||||||
DriftSqlType.bool,
|
DriftSqlType.bool,
|
||||||
data['${effectivePrefix}is_widget_media'],
|
data['${effectivePrefix}is_widget_media'],
|
||||||
)!,
|
)!,
|
||||||
|
isStory: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.bool,
|
||||||
|
data['${effectivePrefix}is_story'],
|
||||||
|
)!,
|
||||||
openedAt: attachedDatabase.typeMapping.read(
|
openedAt: attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.dateTime,
|
DriftSqlType.dateTime,
|
||||||
data['${effectivePrefix}opened_at'],
|
data['${effectivePrefix}opened_at'],
|
||||||
|
|
@ -5734,6 +5760,9 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
final String? quotesMessageId;
|
final String? quotesMessageId;
|
||||||
final bool isDeletedFromSender;
|
final bool isDeletedFromSender;
|
||||||
final bool isWidgetMedia;
|
final bool isWidgetMedia;
|
||||||
|
|
||||||
|
/// A story item: hidden from the chat until saved, gone 24h after sending.
|
||||||
|
final bool isStory;
|
||||||
final DateTime? openedAt;
|
final DateTime? openedAt;
|
||||||
final DateTime? openedByAll;
|
final DateTime? openedByAll;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
|
@ -5754,6 +5783,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
this.quotesMessageId,
|
this.quotesMessageId,
|
||||||
required this.isDeletedFromSender,
|
required this.isDeletedFromSender,
|
||||||
required this.isWidgetMedia,
|
required this.isWidgetMedia,
|
||||||
|
required this.isStory,
|
||||||
this.openedAt,
|
this.openedAt,
|
||||||
this.openedByAll,
|
this.openedByAll,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
|
@ -5791,6 +5821,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
}
|
}
|
||||||
map['is_deleted_from_sender'] = Variable<bool>(isDeletedFromSender);
|
map['is_deleted_from_sender'] = Variable<bool>(isDeletedFromSender);
|
||||||
map['is_widget_media'] = Variable<bool>(isWidgetMedia);
|
map['is_widget_media'] = Variable<bool>(isWidgetMedia);
|
||||||
|
map['is_story'] = Variable<bool>(isStory);
|
||||||
if (!nullToAbsent || openedAt != null) {
|
if (!nullToAbsent || openedAt != null) {
|
||||||
map['opened_at'] = Variable<DateTime>(openedAt);
|
map['opened_at'] = Variable<DateTime>(openedAt);
|
||||||
}
|
}
|
||||||
|
|
@ -5837,6 +5868,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
: Value(quotesMessageId),
|
: Value(quotesMessageId),
|
||||||
isDeletedFromSender: Value(isDeletedFromSender),
|
isDeletedFromSender: Value(isDeletedFromSender),
|
||||||
isWidgetMedia: Value(isWidgetMedia),
|
isWidgetMedia: Value(isWidgetMedia),
|
||||||
|
isStory: Value(isStory),
|
||||||
openedAt: openedAt == null && nullToAbsent
|
openedAt: openedAt == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(openedAt),
|
: Value(openedAt),
|
||||||
|
|
@ -5879,6 +5911,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
json['isDeletedFromSender'],
|
json['isDeletedFromSender'],
|
||||||
),
|
),
|
||||||
isWidgetMedia: serializer.fromJson<bool>(json['isWidgetMedia']),
|
isWidgetMedia: serializer.fromJson<bool>(json['isWidgetMedia']),
|
||||||
|
isStory: serializer.fromJson<bool>(json['isStory']),
|
||||||
openedAt: serializer.fromJson<DateTime?>(json['openedAt']),
|
openedAt: serializer.fromJson<DateTime?>(json['openedAt']),
|
||||||
openedByAll: serializer.fromJson<DateTime?>(json['openedByAll']),
|
openedByAll: serializer.fromJson<DateTime?>(json['openedByAll']),
|
||||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||||
|
|
@ -5906,6 +5939,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
'quotesMessageId': serializer.toJson<String?>(quotesMessageId),
|
'quotesMessageId': serializer.toJson<String?>(quotesMessageId),
|
||||||
'isDeletedFromSender': serializer.toJson<bool>(isDeletedFromSender),
|
'isDeletedFromSender': serializer.toJson<bool>(isDeletedFromSender),
|
||||||
'isWidgetMedia': serializer.toJson<bool>(isWidgetMedia),
|
'isWidgetMedia': serializer.toJson<bool>(isWidgetMedia),
|
||||||
|
'isStory': serializer.toJson<bool>(isStory),
|
||||||
'openedAt': serializer.toJson<DateTime?>(openedAt),
|
'openedAt': serializer.toJson<DateTime?>(openedAt),
|
||||||
'openedByAll': serializer.toJson<DateTime?>(openedByAll),
|
'openedByAll': serializer.toJson<DateTime?>(openedByAll),
|
||||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||||
|
|
@ -5929,6 +5963,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
Value<String?> quotesMessageId = const Value.absent(),
|
Value<String?> quotesMessageId = const Value.absent(),
|
||||||
bool? isDeletedFromSender,
|
bool? isDeletedFromSender,
|
||||||
bool? isWidgetMedia,
|
bool? isWidgetMedia,
|
||||||
|
bool? isStory,
|
||||||
Value<DateTime?> openedAt = const Value.absent(),
|
Value<DateTime?> openedAt = const Value.absent(),
|
||||||
Value<DateTime?> openedByAll = const Value.absent(),
|
Value<DateTime?> openedByAll = const Value.absent(),
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
|
@ -5955,6 +5990,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
: this.quotesMessageId,
|
: this.quotesMessageId,
|
||||||
isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender,
|
isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender,
|
||||||
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
||||||
|
isStory: isStory ?? this.isStory,
|
||||||
openedAt: openedAt.present ? openedAt.value : this.openedAt,
|
openedAt: openedAt.present ? openedAt.value : this.openedAt,
|
||||||
openedByAll: openedByAll.present ? openedByAll.value : this.openedByAll,
|
openedByAll: openedByAll.present ? openedByAll.value : this.openedByAll,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
|
@ -5991,6 +6027,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
isWidgetMedia: data.isWidgetMedia.present
|
isWidgetMedia: data.isWidgetMedia.present
|
||||||
? data.isWidgetMedia.value
|
? data.isWidgetMedia.value
|
||||||
: this.isWidgetMedia,
|
: this.isWidgetMedia,
|
||||||
|
isStory: data.isStory.present ? data.isStory.value : this.isStory,
|
||||||
openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt,
|
openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt,
|
||||||
openedByAll: data.openedByAll.present
|
openedByAll: data.openedByAll.present
|
||||||
? data.openedByAll.value
|
? data.openedByAll.value
|
||||||
|
|
@ -6022,6 +6059,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
..write('quotesMessageId: $quotesMessageId, ')
|
..write('quotesMessageId: $quotesMessageId, ')
|
||||||
..write('isDeletedFromSender: $isDeletedFromSender, ')
|
..write('isDeletedFromSender: $isDeletedFromSender, ')
|
||||||
..write('isWidgetMedia: $isWidgetMedia, ')
|
..write('isWidgetMedia: $isWidgetMedia, ')
|
||||||
|
..write('isStory: $isStory, ')
|
||||||
..write('openedAt: $openedAt, ')
|
..write('openedAt: $openedAt, ')
|
||||||
..write('openedByAll: $openedByAll, ')
|
..write('openedByAll: $openedByAll, ')
|
||||||
..write('createdAt: $createdAt, ')
|
..write('createdAt: $createdAt, ')
|
||||||
|
|
@ -6047,6 +6085,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
quotesMessageId,
|
quotesMessageId,
|
||||||
isDeletedFromSender,
|
isDeletedFromSender,
|
||||||
isWidgetMedia,
|
isWidgetMedia,
|
||||||
|
isStory,
|
||||||
openedAt,
|
openedAt,
|
||||||
openedByAll,
|
openedByAll,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
|
@ -6074,6 +6113,7 @@ class Message extends DataClass implements Insertable<Message> {
|
||||||
other.quotesMessageId == this.quotesMessageId &&
|
other.quotesMessageId == this.quotesMessageId &&
|
||||||
other.isDeletedFromSender == this.isDeletedFromSender &&
|
other.isDeletedFromSender == this.isDeletedFromSender &&
|
||||||
other.isWidgetMedia == this.isWidgetMedia &&
|
other.isWidgetMedia == this.isWidgetMedia &&
|
||||||
|
other.isStory == this.isStory &&
|
||||||
other.openedAt == this.openedAt &&
|
other.openedAt == this.openedAt &&
|
||||||
other.openedByAll == this.openedByAll &&
|
other.openedByAll == this.openedByAll &&
|
||||||
other.createdAt == this.createdAt &&
|
other.createdAt == this.createdAt &&
|
||||||
|
|
@ -6096,6 +6136,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
||||||
final Value<String?> quotesMessageId;
|
final Value<String?> quotesMessageId;
|
||||||
final Value<bool> isDeletedFromSender;
|
final Value<bool> isDeletedFromSender;
|
||||||
final Value<bool> isWidgetMedia;
|
final Value<bool> isWidgetMedia;
|
||||||
|
final Value<bool> isStory;
|
||||||
final Value<DateTime?> openedAt;
|
final Value<DateTime?> openedAt;
|
||||||
final Value<DateTime?> openedByAll;
|
final Value<DateTime?> openedByAll;
|
||||||
final Value<DateTime> createdAt;
|
final Value<DateTime> createdAt;
|
||||||
|
|
@ -6117,6 +6158,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
||||||
this.quotesMessageId = const Value.absent(),
|
this.quotesMessageId = const Value.absent(),
|
||||||
this.isDeletedFromSender = const Value.absent(),
|
this.isDeletedFromSender = const Value.absent(),
|
||||||
this.isWidgetMedia = const Value.absent(),
|
this.isWidgetMedia = const Value.absent(),
|
||||||
|
this.isStory = const Value.absent(),
|
||||||
this.openedAt = const Value.absent(),
|
this.openedAt = const Value.absent(),
|
||||||
this.openedByAll = const Value.absent(),
|
this.openedByAll = const Value.absent(),
|
||||||
this.createdAt = const Value.absent(),
|
this.createdAt = const Value.absent(),
|
||||||
|
|
@ -6139,6 +6181,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
||||||
this.quotesMessageId = const Value.absent(),
|
this.quotesMessageId = const Value.absent(),
|
||||||
this.isDeletedFromSender = const Value.absent(),
|
this.isDeletedFromSender = const Value.absent(),
|
||||||
this.isWidgetMedia = const Value.absent(),
|
this.isWidgetMedia = const Value.absent(),
|
||||||
|
this.isStory = const Value.absent(),
|
||||||
this.openedAt = const Value.absent(),
|
this.openedAt = const Value.absent(),
|
||||||
this.openedByAll = const Value.absent(),
|
this.openedByAll = const Value.absent(),
|
||||||
this.createdAt = const Value.absent(),
|
this.createdAt = const Value.absent(),
|
||||||
|
|
@ -6163,6 +6206,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
||||||
Expression<String>? quotesMessageId,
|
Expression<String>? quotesMessageId,
|
||||||
Expression<bool>? isDeletedFromSender,
|
Expression<bool>? isDeletedFromSender,
|
||||||
Expression<bool>? isWidgetMedia,
|
Expression<bool>? isWidgetMedia,
|
||||||
|
Expression<bool>? isStory,
|
||||||
Expression<DateTime>? openedAt,
|
Expression<DateTime>? openedAt,
|
||||||
Expression<DateTime>? openedByAll,
|
Expression<DateTime>? openedByAll,
|
||||||
Expression<DateTime>? createdAt,
|
Expression<DateTime>? createdAt,
|
||||||
|
|
@ -6187,6 +6231,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
||||||
if (isDeletedFromSender != null)
|
if (isDeletedFromSender != null)
|
||||||
'is_deleted_from_sender': isDeletedFromSender,
|
'is_deleted_from_sender': isDeletedFromSender,
|
||||||
if (isWidgetMedia != null) 'is_widget_media': isWidgetMedia,
|
if (isWidgetMedia != null) 'is_widget_media': isWidgetMedia,
|
||||||
|
if (isStory != null) 'is_story': isStory,
|
||||||
if (openedAt != null) 'opened_at': openedAt,
|
if (openedAt != null) 'opened_at': openedAt,
|
||||||
if (openedByAll != null) 'opened_by_all': openedByAll,
|
if (openedByAll != null) 'opened_by_all': openedByAll,
|
||||||
if (createdAt != null) 'created_at': createdAt,
|
if (createdAt != null) 'created_at': createdAt,
|
||||||
|
|
@ -6211,6 +6256,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
||||||
Value<String?>? quotesMessageId,
|
Value<String?>? quotesMessageId,
|
||||||
Value<bool>? isDeletedFromSender,
|
Value<bool>? isDeletedFromSender,
|
||||||
Value<bool>? isWidgetMedia,
|
Value<bool>? isWidgetMedia,
|
||||||
|
Value<bool>? isStory,
|
||||||
Value<DateTime?>? openedAt,
|
Value<DateTime?>? openedAt,
|
||||||
Value<DateTime?>? openedByAll,
|
Value<DateTime?>? openedByAll,
|
||||||
Value<DateTime>? createdAt,
|
Value<DateTime>? createdAt,
|
||||||
|
|
@ -6234,6 +6280,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
||||||
quotesMessageId: quotesMessageId ?? this.quotesMessageId,
|
quotesMessageId: quotesMessageId ?? this.quotesMessageId,
|
||||||
isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender,
|
isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender,
|
||||||
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
||||||
|
isStory: isStory ?? this.isStory,
|
||||||
openedAt: openedAt ?? this.openedAt,
|
openedAt: openedAt ?? this.openedAt,
|
||||||
openedByAll: openedByAll ?? this.openedByAll,
|
openedByAll: openedByAll ?? this.openedByAll,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
|
@ -6288,6 +6335,9 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
||||||
if (isWidgetMedia.present) {
|
if (isWidgetMedia.present) {
|
||||||
map['is_widget_media'] = Variable<bool>(isWidgetMedia.value);
|
map['is_widget_media'] = Variable<bool>(isWidgetMedia.value);
|
||||||
}
|
}
|
||||||
|
if (isStory.present) {
|
||||||
|
map['is_story'] = Variable<bool>(isStory.value);
|
||||||
|
}
|
||||||
if (openedAt.present) {
|
if (openedAt.present) {
|
||||||
map['opened_at'] = Variable<DateTime>(openedAt.value);
|
map['opened_at'] = Variable<DateTime>(openedAt.value);
|
||||||
}
|
}
|
||||||
|
|
@ -6328,6 +6378,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
||||||
..write('quotesMessageId: $quotesMessageId, ')
|
..write('quotesMessageId: $quotesMessageId, ')
|
||||||
..write('isDeletedFromSender: $isDeletedFromSender, ')
|
..write('isDeletedFromSender: $isDeletedFromSender, ')
|
||||||
..write('isWidgetMedia: $isWidgetMedia, ')
|
..write('isWidgetMedia: $isWidgetMedia, ')
|
||||||
|
..write('isStory: $isStory, ')
|
||||||
..write('openedAt: $openedAt, ')
|
..write('openedAt: $openedAt, ')
|
||||||
..write('openedByAll: $openedByAll, ')
|
..write('openedByAll: $openedByAll, ')
|
||||||
..write('createdAt: $createdAt, ')
|
..write('createdAt: $createdAt, ')
|
||||||
|
|
@ -12081,6 +12132,21 @@ class $ContactGroupsTable extends ContactGroups
|
||||||
),
|
),
|
||||||
defaultValue: const Constant(true),
|
defaultValue: const Constant(true),
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _shareStoriesMeta = const VerificationMeta(
|
||||||
|
'shareStories',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<bool> shareStories = GeneratedColumn<bool>(
|
||||||
|
'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(
|
static const VerificationMeta _usageCounterMeta = const VerificationMeta(
|
||||||
'usageCounter',
|
'usageCounter',
|
||||||
);
|
);
|
||||||
|
|
@ -12114,6 +12180,7 @@ class $ContactGroupsTable extends ContactGroups
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
showAsShortcut,
|
showAsShortcut,
|
||||||
showAsLabel,
|
showAsLabel,
|
||||||
|
shareStories,
|
||||||
usageCounter,
|
usageCounter,
|
||||||
createdAt,
|
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')) {
|
if (data.containsKey('usage_counter')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_usageCounterMeta,
|
_usageCounterMeta,
|
||||||
|
|
@ -12235,6 +12311,10 @@ class $ContactGroupsTable extends ContactGroups
|
||||||
DriftSqlType.bool,
|
DriftSqlType.bool,
|
||||||
data['${effectivePrefix}show_as_label'],
|
data['${effectivePrefix}show_as_label'],
|
||||||
)!,
|
)!,
|
||||||
|
shareStories: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.bool,
|
||||||
|
data['${effectivePrefix}share_stories'],
|
||||||
|
)!,
|
||||||
usageCounter: attachedDatabase.typeMapping.read(
|
usageCounter: attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.int,
|
DriftSqlType.int,
|
||||||
data['${effectivePrefix}usage_counter'],
|
data['${effectivePrefix}usage_counter'],
|
||||||
|
|
@ -12260,6 +12340,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
final int backgroundColor;
|
final int backgroundColor;
|
||||||
final bool showAsShortcut;
|
final bool showAsShortcut;
|
||||||
final bool showAsLabel;
|
final bool showAsLabel;
|
||||||
|
final bool shareStories;
|
||||||
final int usageCounter;
|
final int usageCounter;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
const ContactGroup({
|
const ContactGroup({
|
||||||
|
|
@ -12270,6 +12351,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
required this.backgroundColor,
|
required this.backgroundColor,
|
||||||
required this.showAsShortcut,
|
required this.showAsShortcut,
|
||||||
required this.showAsLabel,
|
required this.showAsLabel,
|
||||||
|
required this.shareStories,
|
||||||
required this.usageCounter,
|
required this.usageCounter,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
@ -12285,6 +12367,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
map['background_color'] = Variable<int>(backgroundColor);
|
map['background_color'] = Variable<int>(backgroundColor);
|
||||||
map['show_as_shortcut'] = Variable<bool>(showAsShortcut);
|
map['show_as_shortcut'] = Variable<bool>(showAsShortcut);
|
||||||
map['show_as_label'] = Variable<bool>(showAsLabel);
|
map['show_as_label'] = Variable<bool>(showAsLabel);
|
||||||
|
map['share_stories'] = Variable<bool>(shareStories);
|
||||||
map['usage_counter'] = Variable<int>(usageCounter);
|
map['usage_counter'] = Variable<int>(usageCounter);
|
||||||
map['created_at'] = Variable<DateTime>(createdAt);
|
map['created_at'] = Variable<DateTime>(createdAt);
|
||||||
return map;
|
return map;
|
||||||
|
|
@ -12301,6 +12384,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
backgroundColor: Value(backgroundColor),
|
backgroundColor: Value(backgroundColor),
|
||||||
showAsShortcut: Value(showAsShortcut),
|
showAsShortcut: Value(showAsShortcut),
|
||||||
showAsLabel: Value(showAsLabel),
|
showAsLabel: Value(showAsLabel),
|
||||||
|
shareStories: Value(shareStories),
|
||||||
usageCounter: Value(usageCounter),
|
usageCounter: Value(usageCounter),
|
||||||
createdAt: Value(createdAt),
|
createdAt: Value(createdAt),
|
||||||
);
|
);
|
||||||
|
|
@ -12319,6 +12403,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
backgroundColor: serializer.fromJson<int>(json['backgroundColor']),
|
backgroundColor: serializer.fromJson<int>(json['backgroundColor']),
|
||||||
showAsShortcut: serializer.fromJson<bool>(json['showAsShortcut']),
|
showAsShortcut: serializer.fromJson<bool>(json['showAsShortcut']),
|
||||||
showAsLabel: serializer.fromJson<bool>(json['showAsLabel']),
|
showAsLabel: serializer.fromJson<bool>(json['showAsLabel']),
|
||||||
|
shareStories: serializer.fromJson<bool>(json['shareStories']),
|
||||||
usageCounter: serializer.fromJson<int>(json['usageCounter']),
|
usageCounter: serializer.fromJson<int>(json['usageCounter']),
|
||||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||||
);
|
);
|
||||||
|
|
@ -12334,6 +12419,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
'backgroundColor': serializer.toJson<int>(backgroundColor),
|
'backgroundColor': serializer.toJson<int>(backgroundColor),
|
||||||
'showAsShortcut': serializer.toJson<bool>(showAsShortcut),
|
'showAsShortcut': serializer.toJson<bool>(showAsShortcut),
|
||||||
'showAsLabel': serializer.toJson<bool>(showAsLabel),
|
'showAsLabel': serializer.toJson<bool>(showAsLabel),
|
||||||
|
'shareStories': serializer.toJson<bool>(shareStories),
|
||||||
'usageCounter': serializer.toJson<int>(usageCounter),
|
'usageCounter': serializer.toJson<int>(usageCounter),
|
||||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||||
};
|
};
|
||||||
|
|
@ -12347,6 +12433,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
int? backgroundColor,
|
int? backgroundColor,
|
||||||
bool? showAsShortcut,
|
bool? showAsShortcut,
|
||||||
bool? showAsLabel,
|
bool? showAsLabel,
|
||||||
|
bool? shareStories,
|
||||||
int? usageCounter,
|
int? usageCounter,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
}) => ContactGroup(
|
}) => ContactGroup(
|
||||||
|
|
@ -12357,6 +12444,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||||
showAsShortcut: showAsShortcut ?? this.showAsShortcut,
|
showAsShortcut: showAsShortcut ?? this.showAsShortcut,
|
||||||
showAsLabel: showAsLabel ?? this.showAsLabel,
|
showAsLabel: showAsLabel ?? this.showAsLabel,
|
||||||
|
shareStories: shareStories ?? this.shareStories,
|
||||||
usageCounter: usageCounter ?? this.usageCounter,
|
usageCounter: usageCounter ?? this.usageCounter,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
);
|
);
|
||||||
|
|
@ -12375,6 +12463,9 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
showAsLabel: data.showAsLabel.present
|
showAsLabel: data.showAsLabel.present
|
||||||
? data.showAsLabel.value
|
? data.showAsLabel.value
|
||||||
: this.showAsLabel,
|
: this.showAsLabel,
|
||||||
|
shareStories: data.shareStories.present
|
||||||
|
? data.shareStories.value
|
||||||
|
: this.shareStories,
|
||||||
usageCounter: data.usageCounter.present
|
usageCounter: data.usageCounter.present
|
||||||
? data.usageCounter.value
|
? data.usageCounter.value
|
||||||
: this.usageCounter,
|
: this.usageCounter,
|
||||||
|
|
@ -12392,6 +12483,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
..write('backgroundColor: $backgroundColor, ')
|
..write('backgroundColor: $backgroundColor, ')
|
||||||
..write('showAsShortcut: $showAsShortcut, ')
|
..write('showAsShortcut: $showAsShortcut, ')
|
||||||
..write('showAsLabel: $showAsLabel, ')
|
..write('showAsLabel: $showAsLabel, ')
|
||||||
|
..write('shareStories: $shareStories, ')
|
||||||
..write('usageCounter: $usageCounter, ')
|
..write('usageCounter: $usageCounter, ')
|
||||||
..write('createdAt: $createdAt')
|
..write('createdAt: $createdAt')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
|
|
@ -12407,6 +12499,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
showAsShortcut,
|
showAsShortcut,
|
||||||
showAsLabel,
|
showAsLabel,
|
||||||
|
shareStories,
|
||||||
usageCounter,
|
usageCounter,
|
||||||
createdAt,
|
createdAt,
|
||||||
);
|
);
|
||||||
|
|
@ -12421,6 +12514,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
||||||
other.backgroundColor == this.backgroundColor &&
|
other.backgroundColor == this.backgroundColor &&
|
||||||
other.showAsShortcut == this.showAsShortcut &&
|
other.showAsShortcut == this.showAsShortcut &&
|
||||||
other.showAsLabel == this.showAsLabel &&
|
other.showAsLabel == this.showAsLabel &&
|
||||||
|
other.shareStories == this.shareStories &&
|
||||||
other.usageCounter == this.usageCounter &&
|
other.usageCounter == this.usageCounter &&
|
||||||
other.createdAt == this.createdAt);
|
other.createdAt == this.createdAt);
|
||||||
}
|
}
|
||||||
|
|
@ -12433,6 +12527,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
||||||
final Value<int> backgroundColor;
|
final Value<int> backgroundColor;
|
||||||
final Value<bool> showAsShortcut;
|
final Value<bool> showAsShortcut;
|
||||||
final Value<bool> showAsLabel;
|
final Value<bool> showAsLabel;
|
||||||
|
final Value<bool> shareStories;
|
||||||
final Value<int> usageCounter;
|
final Value<int> usageCounter;
|
||||||
final Value<DateTime> createdAt;
|
final Value<DateTime> createdAt;
|
||||||
const ContactGroupsCompanion({
|
const ContactGroupsCompanion({
|
||||||
|
|
@ -12443,6 +12538,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
||||||
this.backgroundColor = const Value.absent(),
|
this.backgroundColor = const Value.absent(),
|
||||||
this.showAsShortcut = const Value.absent(),
|
this.showAsShortcut = const Value.absent(),
|
||||||
this.showAsLabel = const Value.absent(),
|
this.showAsLabel = const Value.absent(),
|
||||||
|
this.shareStories = const Value.absent(),
|
||||||
this.usageCounter = const Value.absent(),
|
this.usageCounter = const Value.absent(),
|
||||||
this.createdAt = const Value.absent(),
|
this.createdAt = const Value.absent(),
|
||||||
});
|
});
|
||||||
|
|
@ -12454,6 +12550,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
||||||
required int backgroundColor,
|
required int backgroundColor,
|
||||||
this.showAsShortcut = const Value.absent(),
|
this.showAsShortcut = const Value.absent(),
|
||||||
this.showAsLabel = const Value.absent(),
|
this.showAsLabel = const Value.absent(),
|
||||||
|
this.shareStories = const Value.absent(),
|
||||||
this.usageCounter = const Value.absent(),
|
this.usageCounter = const Value.absent(),
|
||||||
this.createdAt = const Value.absent(),
|
this.createdAt = const Value.absent(),
|
||||||
}) : name = Value(name),
|
}) : name = Value(name),
|
||||||
|
|
@ -12467,6 +12564,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
||||||
Expression<int>? backgroundColor,
|
Expression<int>? backgroundColor,
|
||||||
Expression<bool>? showAsShortcut,
|
Expression<bool>? showAsShortcut,
|
||||||
Expression<bool>? showAsLabel,
|
Expression<bool>? showAsLabel,
|
||||||
|
Expression<bool>? shareStories,
|
||||||
Expression<int>? usageCounter,
|
Expression<int>? usageCounter,
|
||||||
Expression<DateTime>? createdAt,
|
Expression<DateTime>? createdAt,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -12478,6 +12576,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
||||||
if (backgroundColor != null) 'background_color': backgroundColor,
|
if (backgroundColor != null) 'background_color': backgroundColor,
|
||||||
if (showAsShortcut != null) 'show_as_shortcut': showAsShortcut,
|
if (showAsShortcut != null) 'show_as_shortcut': showAsShortcut,
|
||||||
if (showAsLabel != null) 'show_as_label': showAsLabel,
|
if (showAsLabel != null) 'show_as_label': showAsLabel,
|
||||||
|
if (shareStories != null) 'share_stories': shareStories,
|
||||||
if (usageCounter != null) 'usage_counter': usageCounter,
|
if (usageCounter != null) 'usage_counter': usageCounter,
|
||||||
if (createdAt != null) 'created_at': createdAt,
|
if (createdAt != null) 'created_at': createdAt,
|
||||||
});
|
});
|
||||||
|
|
@ -12491,6 +12590,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
||||||
Value<int>? backgroundColor,
|
Value<int>? backgroundColor,
|
||||||
Value<bool>? showAsShortcut,
|
Value<bool>? showAsShortcut,
|
||||||
Value<bool>? showAsLabel,
|
Value<bool>? showAsLabel,
|
||||||
|
Value<bool>? shareStories,
|
||||||
Value<int>? usageCounter,
|
Value<int>? usageCounter,
|
||||||
Value<DateTime>? createdAt,
|
Value<DateTime>? createdAt,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -12502,6 +12602,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
||||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||||
showAsShortcut: showAsShortcut ?? this.showAsShortcut,
|
showAsShortcut: showAsShortcut ?? this.showAsShortcut,
|
||||||
showAsLabel: showAsLabel ?? this.showAsLabel,
|
showAsLabel: showAsLabel ?? this.showAsLabel,
|
||||||
|
shareStories: shareStories ?? this.shareStories,
|
||||||
usageCounter: usageCounter ?? this.usageCounter,
|
usageCounter: usageCounter ?? this.usageCounter,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
);
|
);
|
||||||
|
|
@ -12531,6 +12632,9 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
||||||
if (showAsLabel.present) {
|
if (showAsLabel.present) {
|
||||||
map['show_as_label'] = Variable<bool>(showAsLabel.value);
|
map['show_as_label'] = Variable<bool>(showAsLabel.value);
|
||||||
}
|
}
|
||||||
|
if (shareStories.present) {
|
||||||
|
map['share_stories'] = Variable<bool>(shareStories.value);
|
||||||
|
}
|
||||||
if (usageCounter.present) {
|
if (usageCounter.present) {
|
||||||
map['usage_counter'] = Variable<int>(usageCounter.value);
|
map['usage_counter'] = Variable<int>(usageCounter.value);
|
||||||
}
|
}
|
||||||
|
|
@ -12550,6 +12654,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
||||||
..write('backgroundColor: $backgroundColor, ')
|
..write('backgroundColor: $backgroundColor, ')
|
||||||
..write('showAsShortcut: $showAsShortcut, ')
|
..write('showAsShortcut: $showAsShortcut, ')
|
||||||
..write('showAsLabel: $showAsLabel, ')
|
..write('showAsLabel: $showAsLabel, ')
|
||||||
|
..write('shareStories: $shareStories, ')
|
||||||
..write('usageCounter: $usageCounter, ')
|
..write('usageCounter: $usageCounter, ')
|
||||||
..write('createdAt: $createdAt')
|
..write('createdAt: $createdAt')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
|
|
@ -14928,6 +15033,7 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
|
||||||
late final ContactGroupsDao contactGroupsDao = ContactGroupsDao(
|
late final ContactGroupsDao contactGroupsDao = ContactGroupsDao(
|
||||||
this as TwonlyDB,
|
this as TwonlyDB,
|
||||||
);
|
);
|
||||||
|
late final StoriesDao storiesDao = StoriesDao(this as TwonlyDB);
|
||||||
@override
|
@override
|
||||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
|
|
@ -16713,7 +16819,7 @@ class $$ContactsTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$ContactsTable, Contact>(table),
|
||||||
$$ContactsTableReferences(db, table, e),
|
$$ContactsTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -18000,8 +18106,10 @@ class $$GroupsTableTableManager
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) =>
|
(e) => (
|
||||||
(e.readTable(table), $$GroupsTableReferences(db, table, e)),
|
e.readTable<$GroupsTable, Group>(table),
|
||||||
|
$$GroupsTableReferences(db, table, e),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
prefetchHooksCallback:
|
prefetchHooksCallback:
|
||||||
|
|
@ -18996,7 +19104,7 @@ class $$MediaFilesTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$MediaFilesTable, MediaFile>(table),
|
||||||
$$MediaFilesTableReferences(db, table, e),
|
$$MediaFilesTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -19066,6 +19174,7 @@ typedef $$MessagesTableCreateCompanionBuilder =
|
||||||
Value<String?> quotesMessageId,
|
Value<String?> quotesMessageId,
|
||||||
Value<bool> isDeletedFromSender,
|
Value<bool> isDeletedFromSender,
|
||||||
Value<bool> isWidgetMedia,
|
Value<bool> isWidgetMedia,
|
||||||
|
Value<bool> isStory,
|
||||||
Value<DateTime?> openedAt,
|
Value<DateTime?> openedAt,
|
||||||
Value<DateTime?> openedByAll,
|
Value<DateTime?> openedByAll,
|
||||||
Value<DateTime> createdAt,
|
Value<DateTime> createdAt,
|
||||||
|
|
@ -19089,6 +19198,7 @@ typedef $$MessagesTableUpdateCompanionBuilder =
|
||||||
Value<String?> quotesMessageId,
|
Value<String?> quotesMessageId,
|
||||||
Value<bool> isDeletedFromSender,
|
Value<bool> isDeletedFromSender,
|
||||||
Value<bool> isWidgetMedia,
|
Value<bool> isWidgetMedia,
|
||||||
|
Value<bool> isStory,
|
||||||
Value<DateTime?> openedAt,
|
Value<DateTime?> openedAt,
|
||||||
Value<DateTime?> openedByAll,
|
Value<DateTime?> openedByAll,
|
||||||
Value<DateTime> createdAt,
|
Value<DateTime> createdAt,
|
||||||
|
|
@ -19314,6 +19424,11 @@ class $$MessagesTableFilterComposer
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<bool> get isStory => $composableBuilder(
|
||||||
|
column: $table.isStory,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnFilters<DateTime> get openedAt => $composableBuilder(
|
ColumnFilters<DateTime> get openedAt => $composableBuilder(
|
||||||
column: $table.openedAt,
|
column: $table.openedAt,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
|
|
@ -19598,6 +19713,11 @@ class $$MessagesTableOrderingComposer
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<bool> get isStory => $composableBuilder(
|
||||||
|
column: $table.isStory,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnOrderings<DateTime> get openedAt => $composableBuilder(
|
ColumnOrderings<DateTime> get openedAt => $composableBuilder(
|
||||||
column: $table.openedAt,
|
column: $table.openedAt,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
|
@ -19751,6 +19871,9 @@ class $$MessagesTableAnnotationComposer
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<bool> get isStory =>
|
||||||
|
$composableBuilder(column: $table.isStory, builder: (column) => column);
|
||||||
|
|
||||||
GeneratedColumn<DateTime> get openedAt =>
|
GeneratedColumn<DateTime> get openedAt =>
|
||||||
$composableBuilder(column: $table.openedAt, builder: (column) => column);
|
$composableBuilder(column: $table.openedAt, builder: (column) => column);
|
||||||
|
|
||||||
|
|
@ -20020,6 +20143,7 @@ class $$MessagesTableTableManager
|
||||||
Value<String?> quotesMessageId = const Value.absent(),
|
Value<String?> quotesMessageId = const Value.absent(),
|
||||||
Value<bool> isDeletedFromSender = const Value.absent(),
|
Value<bool> isDeletedFromSender = const Value.absent(),
|
||||||
Value<bool> isWidgetMedia = const Value.absent(),
|
Value<bool> isWidgetMedia = const Value.absent(),
|
||||||
|
Value<bool> isStory = const Value.absent(),
|
||||||
Value<DateTime?> openedAt = const Value.absent(),
|
Value<DateTime?> openedAt = const Value.absent(),
|
||||||
Value<DateTime?> openedByAll = const Value.absent(),
|
Value<DateTime?> openedByAll = const Value.absent(),
|
||||||
Value<DateTime> createdAt = const Value.absent(),
|
Value<DateTime> createdAt = const Value.absent(),
|
||||||
|
|
@ -20041,6 +20165,7 @@ class $$MessagesTableTableManager
|
||||||
quotesMessageId: quotesMessageId,
|
quotesMessageId: quotesMessageId,
|
||||||
isDeletedFromSender: isDeletedFromSender,
|
isDeletedFromSender: isDeletedFromSender,
|
||||||
isWidgetMedia: isWidgetMedia,
|
isWidgetMedia: isWidgetMedia,
|
||||||
|
isStory: isStory,
|
||||||
openedAt: openedAt,
|
openedAt: openedAt,
|
||||||
openedByAll: openedByAll,
|
openedByAll: openedByAll,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
|
|
@ -20064,6 +20189,7 @@ class $$MessagesTableTableManager
|
||||||
Value<String?> quotesMessageId = const Value.absent(),
|
Value<String?> quotesMessageId = const Value.absent(),
|
||||||
Value<bool> isDeletedFromSender = const Value.absent(),
|
Value<bool> isDeletedFromSender = const Value.absent(),
|
||||||
Value<bool> isWidgetMedia = const Value.absent(),
|
Value<bool> isWidgetMedia = const Value.absent(),
|
||||||
|
Value<bool> isStory = const Value.absent(),
|
||||||
Value<DateTime?> openedAt = const Value.absent(),
|
Value<DateTime?> openedAt = const Value.absent(),
|
||||||
Value<DateTime?> openedByAll = const Value.absent(),
|
Value<DateTime?> openedByAll = const Value.absent(),
|
||||||
Value<DateTime> createdAt = const Value.absent(),
|
Value<DateTime> createdAt = const Value.absent(),
|
||||||
|
|
@ -20085,6 +20211,7 @@ class $$MessagesTableTableManager
|
||||||
quotesMessageId: quotesMessageId,
|
quotesMessageId: quotesMessageId,
|
||||||
isDeletedFromSender: isDeletedFromSender,
|
isDeletedFromSender: isDeletedFromSender,
|
||||||
isWidgetMedia: isWidgetMedia,
|
isWidgetMedia: isWidgetMedia,
|
||||||
|
isStory: isStory,
|
||||||
openedAt: openedAt,
|
openedAt: openedAt,
|
||||||
openedByAll: openedByAll,
|
openedByAll: openedByAll,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
|
|
@ -20096,7 +20223,7 @@ class $$MessagesTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$MessagesTable, Message>(table),
|
||||||
$$MessagesTableReferences(db, table, e),
|
$$MessagesTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -20643,7 +20770,7 @@ class $$MessageHistoriesTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$MessageHistoriesTable, MessageHistory>(table),
|
||||||
$$MessageHistoriesTableReferences(db, table, e),
|
$$MessageHistoriesTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -21031,7 +21158,7 @@ class $$ReactionsTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$ReactionsTable, Reaction>(table),
|
||||||
$$ReactionsTableReferences(db, table, e),
|
$$ReactionsTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -21503,7 +21630,7 @@ class $$GroupMembersTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$GroupMembersTable, GroupMember>(table),
|
||||||
$$GroupMembersTableReferences(db, table, e),
|
$$GroupMembersTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -22053,7 +22180,7 @@ class $$ReceiptsTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$ReceiptsTable, Receipt>(table),
|
||||||
$$ReceiptsTableReferences(db, table, e),
|
$$ReceiptsTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -22251,7 +22378,16 @@ class $$ReceivedReceiptsTableTableManager
|
||||||
rowid: rowid,
|
rowid: rowid,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
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(),
|
.toList(),
|
||||||
prefetchHooksCallback: null,
|
prefetchHooksCallback: null,
|
||||||
),
|
),
|
||||||
|
|
@ -22587,7 +22723,7 @@ class $$MessageActionsTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$MessageActionsTable, MessageAction>(table),
|
||||||
$$MessageActionsTableReferences(db, table, e),
|
$$MessageActionsTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -23090,7 +23226,7 @@ class $$GroupHistoriesTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$GroupHistoriesTable, GroupHistory>(table),
|
||||||
$$GroupHistoriesTableReferences(db, table, e),
|
$$GroupHistoriesTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -23499,7 +23635,7 @@ class $$KeyVerificationsTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$KeyVerificationsTable, KeyVerification>(table),
|
||||||
$$KeyVerificationsTableReferences(db, table, e),
|
$$KeyVerificationsTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -23721,7 +23857,18 @@ class $$VerificationTokensTableTableManager
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
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(),
|
.toList(),
|
||||||
prefetchHooksCallback: null,
|
prefetchHooksCallback: null,
|
||||||
),
|
),
|
||||||
|
|
@ -24076,7 +24223,10 @@ class $$UserDiscoveryAnnouncedUsersTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<
|
||||||
|
$UserDiscoveryAnnouncedUsersTable,
|
||||||
|
UserDiscoveryAnnouncedUser
|
||||||
|
>(table),
|
||||||
$$UserDiscoveryAnnouncedUsersTableReferences(db, table, e),
|
$$UserDiscoveryAnnouncedUsersTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -24463,7 +24613,10 @@ class $$UserDiscoveryUserRelationsTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<
|
||||||
|
$UserDiscoveryUserRelationsTable,
|
||||||
|
UserDiscoveryUserRelation
|
||||||
|
>(table),
|
||||||
$$UserDiscoveryUserRelationsTableReferences(db, table, e),
|
$$UserDiscoveryUserRelationsTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -24851,7 +25004,10 @@ class $$UserDiscoveryOtherPromotionsTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<
|
||||||
|
$UserDiscoveryOtherPromotionsTable,
|
||||||
|
UserDiscoveryOtherPromotion
|
||||||
|
>(table),
|
||||||
$$UserDiscoveryOtherPromotionsTableReferences(db, table, e),
|
$$UserDiscoveryOtherPromotionsTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -25154,7 +25310,10 @@ class $$UserDiscoveryOwnPromotionsTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<
|
||||||
|
$UserDiscoveryOwnPromotionsTable,
|
||||||
|
UserDiscoveryOwnPromotion
|
||||||
|
>(table),
|
||||||
$$UserDiscoveryOwnPromotionsTableReferences(db, table, e),
|
$$UserDiscoveryOwnPromotionsTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -25446,7 +25605,9 @@ class $$UserDiscoverySharesTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$UserDiscoverySharesTable, UserDiscoveryShare>(
|
||||||
|
table,
|
||||||
|
),
|
||||||
$$UserDiscoverySharesTableReferences(db, table, e),
|
$$UserDiscoverySharesTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -25521,6 +25682,7 @@ typedef $$ContactGroupsTableCreateCompanionBuilder =
|
||||||
required int backgroundColor,
|
required int backgroundColor,
|
||||||
Value<bool> showAsShortcut,
|
Value<bool> showAsShortcut,
|
||||||
Value<bool> showAsLabel,
|
Value<bool> showAsLabel,
|
||||||
|
Value<bool> shareStories,
|
||||||
Value<int> usageCounter,
|
Value<int> usageCounter,
|
||||||
Value<DateTime> createdAt,
|
Value<DateTime> createdAt,
|
||||||
});
|
});
|
||||||
|
|
@ -25533,6 +25695,7 @@ typedef $$ContactGroupsTableUpdateCompanionBuilder =
|
||||||
Value<int> backgroundColor,
|
Value<int> backgroundColor,
|
||||||
Value<bool> showAsShortcut,
|
Value<bool> showAsShortcut,
|
||||||
Value<bool> showAsLabel,
|
Value<bool> showAsLabel,
|
||||||
|
Value<bool> shareStories,
|
||||||
Value<int> usageCounter,
|
Value<int> usageCounter,
|
||||||
Value<DateTime> createdAt,
|
Value<DateTime> createdAt,
|
||||||
});
|
});
|
||||||
|
|
@ -25613,6 +25776,11 @@ class $$ContactGroupsTableFilterComposer
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<bool> get shareStories => $composableBuilder(
|
||||||
|
column: $table.shareStories,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnFilters<int> get usageCounter => $composableBuilder(
|
ColumnFilters<int> get usageCounter => $composableBuilder(
|
||||||
column: $table.usageCounter,
|
column: $table.usageCounter,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
|
|
@ -25693,6 +25861,11 @@ class $$ContactGroupsTableOrderingComposer
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<bool> get shareStories => $composableBuilder(
|
||||||
|
column: $table.shareStories,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnOrderings<int> get usageCounter => $composableBuilder(
|
ColumnOrderings<int> get usageCounter => $composableBuilder(
|
||||||
column: $table.usageCounter,
|
column: $table.usageCounter,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
|
@ -25740,6 +25913,11 @@ class $$ContactGroupsTableAnnotationComposer
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<bool> get shareStories => $composableBuilder(
|
||||||
|
column: $table.shareStories,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
GeneratedColumn<int> get usageCounter => $composableBuilder(
|
GeneratedColumn<int> get usageCounter => $composableBuilder(
|
||||||
column: $table.usageCounter,
|
column: $table.usageCounter,
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
|
|
@ -25810,6 +25988,7 @@ class $$ContactGroupsTableTableManager
|
||||||
Value<int> backgroundColor = const Value.absent(),
|
Value<int> backgroundColor = const Value.absent(),
|
||||||
Value<bool> showAsShortcut = const Value.absent(),
|
Value<bool> showAsShortcut = const Value.absent(),
|
||||||
Value<bool> showAsLabel = const Value.absent(),
|
Value<bool> showAsLabel = const Value.absent(),
|
||||||
|
Value<bool> shareStories = const Value.absent(),
|
||||||
Value<int> usageCounter = const Value.absent(),
|
Value<int> usageCounter = const Value.absent(),
|
||||||
Value<DateTime> createdAt = const Value.absent(),
|
Value<DateTime> createdAt = const Value.absent(),
|
||||||
}) => ContactGroupsCompanion(
|
}) => ContactGroupsCompanion(
|
||||||
|
|
@ -25820,6 +25999,7 @@ class $$ContactGroupsTableTableManager
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
showAsShortcut: showAsShortcut,
|
showAsShortcut: showAsShortcut,
|
||||||
showAsLabel: showAsLabel,
|
showAsLabel: showAsLabel,
|
||||||
|
shareStories: shareStories,
|
||||||
usageCounter: usageCounter,
|
usageCounter: usageCounter,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
),
|
),
|
||||||
|
|
@ -25832,6 +26012,7 @@ class $$ContactGroupsTableTableManager
|
||||||
required int backgroundColor,
|
required int backgroundColor,
|
||||||
Value<bool> showAsShortcut = const Value.absent(),
|
Value<bool> showAsShortcut = const Value.absent(),
|
||||||
Value<bool> showAsLabel = const Value.absent(),
|
Value<bool> showAsLabel = const Value.absent(),
|
||||||
|
Value<bool> shareStories = const Value.absent(),
|
||||||
Value<int> usageCounter = const Value.absent(),
|
Value<int> usageCounter = const Value.absent(),
|
||||||
Value<DateTime> createdAt = const Value.absent(),
|
Value<DateTime> createdAt = const Value.absent(),
|
||||||
}) => ContactGroupsCompanion.insert(
|
}) => ContactGroupsCompanion.insert(
|
||||||
|
|
@ -25842,13 +26023,14 @@ class $$ContactGroupsTableTableManager
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
showAsShortcut: showAsShortcut,
|
showAsShortcut: showAsShortcut,
|
||||||
showAsLabel: showAsLabel,
|
showAsLabel: showAsLabel,
|
||||||
|
shareStories: shareStories,
|
||||||
usageCounter: usageCounter,
|
usageCounter: usageCounter,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$ContactGroupsTable, ContactGroup>(table),
|
||||||
$$ContactGroupsTableReferences(db, table, e),
|
$$ContactGroupsTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -26304,7 +26486,9 @@ class $$ContactGroupMembersTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$ContactGroupMembersTable, ContactGroupMember>(
|
||||||
|
table,
|
||||||
|
),
|
||||||
$$ContactGroupMembersTableReferences(db, table, e),
|
$$ContactGroupMembersTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -26755,7 +26939,16 @@ class $$WebxdcAppsTableTableManager
|
||||||
rowid: rowid,
|
rowid: rowid,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
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(),
|
.toList(),
|
||||||
prefetchHooksCallback: null,
|
prefetchHooksCallback: null,
|
||||||
),
|
),
|
||||||
|
|
@ -27207,7 +27400,7 @@ class $$WebxdcInstancesTableTableManager
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
(e) => (
|
(e) => (
|
||||||
e.readTable(table),
|
e.readTable<$WebxdcInstancesTable, WebxdcInstance>(table),
|
||||||
$$WebxdcInstancesTableReferences(db, table, e),
|
$$WebxdcInstancesTableReferences(db, table, e),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -27510,7 +27703,16 @@ class $$WebxdcUpdatesTableTableManager
|
||||||
rowid: rowid,
|
rowid: rowid,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
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(),
|
.toList(),
|
||||||
prefetchHooksCallback: null,
|
prefetchHooksCallback: null,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4857,6 +4857,144 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'This link opens outside twonly, in your browser:'**
|
/// **'This link opens outside twonly, in your browser:'**
|
||||||
String get webxdcExternalLinkBody;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2851,4 +2851,97 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get webxdcExternalLinkBody =>
|
String get webxdcExternalLinkBody =>
|
||||||
'يُفتح هذا الرابط خارج twonly، في متصفحك:';
|
'يُفتح هذا الرابط خارج 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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2851,4 +2851,98 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get webxdcExternalLinkBody =>
|
String get webxdcExternalLinkBody =>
|
||||||
'Dieser Link öffnet sich außerhalb von twonly in deinem Browser:';
|
'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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2823,4 +2823,97 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get webxdcExternalLinkBody =>
|
String get webxdcExternalLinkBody =>
|
||||||
'This link opens outside twonly, in your browser:';
|
'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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -792,5 +792,49 @@
|
||||||
"webxdcUnavailable": "تعذّر تحميل هذا التطبيق.",
|
"webxdcUnavailable": "تعذّر تحميل هذا التطبيق.",
|
||||||
"webxdcDeleteConfirm": "سيتم حذف كل ما حفظه هذا التطبيق على هذا الجهاز. يحتفظ الأعضاء الآخرون بنسختهم الخاصة.",
|
"webxdcDeleteConfirm": "سيتم حذف كل ما حفظه هذا التطبيق على هذا الجهاز. يحتفظ الأعضاء الآخرون بنسختهم الخاصة.",
|
||||||
"webxdcExternalLinkTitle": "مغادرة twonly؟",
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1105,5 +1105,49 @@
|
||||||
"webxdcUnavailable": "Diese App konnte nicht geladen werden.",
|
"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.",
|
"webxdcDeleteConfirm": "Alles, was diese App auf diesem Gerät gespeichert hat, wird gelöscht. Andere Mitglieder behalten ihre eigene Kopie.",
|
||||||
"webxdcExternalLinkTitle": "twonly verlassen?",
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1115,5 +1115,49 @@
|
||||||
"webxdcUnavailable": "This app could not be loaded.",
|
"webxdcUnavailable": "This app could not be loaded.",
|
||||||
"webxdcDeleteConfirm": "Everything this app saved on this device is deleted. Other members keep their own copy.",
|
"webxdcDeleteConfirm": "Everything this app saved on this device is deleted. Other members keep their own copy.",
|
||||||
"webxdcExternalLinkTitle": "Leave twonly?",
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -373,12 +373,15 @@ class EncryptedContent_TextMessage extends $pb.GeneratedMessage {
|
||||||
$core.String? text,
|
$core.String? text,
|
||||||
$fixnum.Int64? timestamp,
|
$fixnum.Int64? timestamp,
|
||||||
$core.String? quoteMessageId,
|
$core.String? quoteMessageId,
|
||||||
|
$core.List<$core.int>? additionalMessageData,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (senderMessageId != null) result.senderMessageId = senderMessageId;
|
if (senderMessageId != null) result.senderMessageId = senderMessageId;
|
||||||
if (text != null) result.text = text;
|
if (text != null) result.text = text;
|
||||||
if (timestamp != null) result.timestamp = timestamp;
|
if (timestamp != null) result.timestamp = timestamp;
|
||||||
if (quoteMessageId != null) result.quoteMessageId = quoteMessageId;
|
if (quoteMessageId != null) result.quoteMessageId = quoteMessageId;
|
||||||
|
if (additionalMessageData != null)
|
||||||
|
result.additionalMessageData = additionalMessageData;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,6 +401,8 @@ class EncryptedContent_TextMessage extends $pb.GeneratedMessage {
|
||||||
..aOS(2, _omitFieldNames ? '' : 'text')
|
..aOS(2, _omitFieldNames ? '' : 'text')
|
||||||
..aInt64(3, _omitFieldNames ? '' : 'timestamp')
|
..aInt64(3, _omitFieldNames ? '' : 'timestamp')
|
||||||
..aOS(4, _omitFieldNames ? '' : 'quoteMessageId')
|
..aOS(4, _omitFieldNames ? '' : 'quoteMessageId')
|
||||||
|
..a<$core.List<$core.int>>(
|
||||||
|
5, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY)
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$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);
|
$core.bool hasQuoteMessageId() => $_has(3);
|
||||||
@$pb.TagNumber(4)
|
@$pb.TagNumber(4)
|
||||||
void clearQuoteMessageId() => $_clearField(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 {
|
class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage {
|
||||||
|
|
@ -465,6 +482,7 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage {
|
||||||
$fixnum.Int64? timestamp,
|
$fixnum.Int64? timestamp,
|
||||||
$core.String? type,
|
$core.String? type,
|
||||||
$core.List<$core.int>? additionalMessageData,
|
$core.List<$core.int>? additionalMessageData,
|
||||||
|
$core.bool? hidden,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (senderMessageId != null) result.senderMessageId = senderMessageId;
|
if (senderMessageId != null) result.senderMessageId = senderMessageId;
|
||||||
|
|
@ -472,6 +490,7 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage {
|
||||||
if (type != null) result.type = type;
|
if (type != null) result.type = type;
|
||||||
if (additionalMessageData != null)
|
if (additionalMessageData != null)
|
||||||
result.additionalMessageData = additionalMessageData;
|
result.additionalMessageData = additionalMessageData;
|
||||||
|
if (hidden != null) result.hidden = hidden;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -493,6 +512,7 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage {
|
||||||
..aOS(3, _omitFieldNames ? '' : 'type')
|
..aOS(3, _omitFieldNames ? '' : 'type')
|
||||||
..a<$core.List<$core.int>>(
|
..a<$core.List<$core.int>>(
|
||||||
4, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY)
|
4, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY)
|
||||||
|
..aOB(5, _omitFieldNames ? '' : 'hidden')
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$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);
|
$core.bool hasAdditionalMessageData() => $_has(3);
|
||||||
@$pb.TagNumber(4)
|
@$pb.TagNumber(4)
|
||||||
void clearAdditionalMessageData() => $_clearField(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 {
|
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>? encryptionMac,
|
||||||
$core.List<$core.int>? encryptionNonce,
|
$core.List<$core.int>? encryptionNonce,
|
||||||
$core.List<$core.int>? additionalMessageData,
|
$core.List<$core.int>? additionalMessageData,
|
||||||
|
$core.bool? widgetOnly,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (senderMessageId != null) result.senderMessageId = senderMessageId;
|
if (senderMessageId != null) result.senderMessageId = senderMessageId;
|
||||||
|
|
@ -763,6 +800,7 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
|
||||||
if (encryptionNonce != null) result.encryptionNonce = encryptionNonce;
|
if (encryptionNonce != null) result.encryptionNonce = encryptionNonce;
|
||||||
if (additionalMessageData != null)
|
if (additionalMessageData != null)
|
||||||
result.additionalMessageData = additionalMessageData;
|
result.additionalMessageData = additionalMessageData;
|
||||||
|
if (widgetOnly != null) result.widgetOnly = widgetOnly;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -795,6 +833,7 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
|
||||||
10, _omitFieldNames ? '' : 'encryptionNonce', $pb.PbFieldType.OY)
|
10, _omitFieldNames ? '' : 'encryptionNonce', $pb.PbFieldType.OY)
|
||||||
..a<$core.List<$core.int>>(
|
..a<$core.List<$core.int>>(
|
||||||
11, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY)
|
11, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY)
|
||||||
|
..aOB(12, _omitFieldNames ? '' : 'widgetOnly')
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$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);
|
$core.bool hasAdditionalMessageData() => $_has(10);
|
||||||
@$pb.TagNumber(11)
|
@$pb.TagNumber(11)
|
||||||
void clearAdditionalMessageData() => $_clearField(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<EncryptedContent_Media>(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<EncryptedContent_Story>(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 {
|
class EncryptedContent_MediaUpdate extends $pb.GeneratedMessage {
|
||||||
|
|
@ -1649,6 +1772,8 @@ class EncryptedContent extends $pb.GeneratedMessage {
|
||||||
EncryptedContent_PasswordLessRecovery? passwordlessRecovery,
|
EncryptedContent_PasswordLessRecovery? passwordlessRecovery,
|
||||||
EncryptedContent_PasswordLessRecoveryHeartbeat?
|
EncryptedContent_PasswordLessRecoveryHeartbeat?
|
||||||
passwordlessRecoveryHeartbeat,
|
passwordlessRecoveryHeartbeat,
|
||||||
|
$core.bool? widgetSharingAllowed,
|
||||||
|
EncryptedContent_Story? story,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (groupId != null) result.groupId = groupId;
|
if (groupId != null) result.groupId = groupId;
|
||||||
|
|
@ -1686,6 +1811,9 @@ class EncryptedContent extends $pb.GeneratedMessage {
|
||||||
result.passwordlessRecovery = passwordlessRecovery;
|
result.passwordlessRecovery = passwordlessRecovery;
|
||||||
if (passwordlessRecoveryHeartbeat != null)
|
if (passwordlessRecoveryHeartbeat != null)
|
||||||
result.passwordlessRecoveryHeartbeat = passwordlessRecoveryHeartbeat;
|
result.passwordlessRecoveryHeartbeat = passwordlessRecoveryHeartbeat;
|
||||||
|
if (widgetSharingAllowed != null)
|
||||||
|
result.widgetSharingAllowed = widgetSharingAllowed;
|
||||||
|
if (story != null) result.story = story;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1762,6 +1890,9 @@ class EncryptedContent extends $pb.GeneratedMessage {
|
||||||
..aOM<EncryptedContent_PasswordLessRecoveryHeartbeat>(
|
..aOM<EncryptedContent_PasswordLessRecoveryHeartbeat>(
|
||||||
27, _omitFieldNames ? '' : 'passwordlessRecoveryHeartbeat',
|
27, _omitFieldNames ? '' : 'passwordlessRecoveryHeartbeat',
|
||||||
subBuilder: EncryptedContent_PasswordLessRecoveryHeartbeat.create)
|
subBuilder: EncryptedContent_PasswordLessRecoveryHeartbeat.create)
|
||||||
|
..aOB(29, _omitFieldNames ? '' : 'widgetSharingAllowed')
|
||||||
|
..aOM<EncryptedContent_Story>(30, _omitFieldNames ? '' : 'story',
|
||||||
|
subBuilder: EncryptedContent_Story.create)
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
|
@ -2071,6 +2202,26 @@ class EncryptedContent extends $pb.GeneratedMessage {
|
||||||
@$pb.TagNumber(27)
|
@$pb.TagNumber(27)
|
||||||
EncryptedContent_PasswordLessRecoveryHeartbeat
|
EncryptedContent_PasswordLessRecoveryHeartbeat
|
||||||
ensurePasswordlessRecoveryHeartbeat() => $_ensure(24);
|
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 =
|
const $core.bool _omitFieldNames =
|
||||||
|
|
|
||||||
|
|
@ -64,13 +64,22 @@ const EncryptedContent$json = {
|
||||||
'10': 'askForFriendPromotions',
|
'10': 'askForFriendPromotions',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'1': 'widget_sharing_allowed',
|
||||||
|
'3': 29,
|
||||||
|
'4': 1,
|
||||||
|
'5': 8,
|
||||||
|
'9': 5,
|
||||||
|
'10': 'widgetSharingAllowed',
|
||||||
|
'17': true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'1': 'message_update',
|
'1': 'message_update',
|
||||||
'3': 5,
|
'3': 5,
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.MessageUpdate',
|
'6': '.EncryptedContent.MessageUpdate',
|
||||||
'9': 5,
|
'9': 6,
|
||||||
'10': 'messageUpdate',
|
'10': 'messageUpdate',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -80,7 +89,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.Media',
|
'6': '.EncryptedContent.Media',
|
||||||
'9': 6,
|
'9': 7,
|
||||||
'10': 'media',
|
'10': 'media',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -90,7 +99,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.MediaUpdate',
|
'6': '.EncryptedContent.MediaUpdate',
|
||||||
'9': 7,
|
'9': 8,
|
||||||
'10': 'mediaUpdate',
|
'10': 'mediaUpdate',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -100,7 +109,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.ContactUpdate',
|
'6': '.EncryptedContent.ContactUpdate',
|
||||||
'9': 8,
|
'9': 9,
|
||||||
'10': 'contactUpdate',
|
'10': 'contactUpdate',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -110,7 +119,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.ContactRequest',
|
'6': '.EncryptedContent.ContactRequest',
|
||||||
'9': 9,
|
'9': 10,
|
||||||
'10': 'contactRequest',
|
'10': 'contactRequest',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -120,7 +129,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.FlameSync',
|
'6': '.EncryptedContent.FlameSync',
|
||||||
'9': 10,
|
'9': 11,
|
||||||
'10': 'flameSync',
|
'10': 'flameSync',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -130,7 +139,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.Reaction',
|
'6': '.EncryptedContent.Reaction',
|
||||||
'9': 11,
|
'9': 12,
|
||||||
'10': 'reaction',
|
'10': 'reaction',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -140,7 +149,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.TextMessage',
|
'6': '.EncryptedContent.TextMessage',
|
||||||
'9': 12,
|
'9': 13,
|
||||||
'10': 'textMessage',
|
'10': 'textMessage',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -150,7 +159,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.GroupCreate',
|
'6': '.EncryptedContent.GroupCreate',
|
||||||
'9': 13,
|
'9': 14,
|
||||||
'10': 'groupCreate',
|
'10': 'groupCreate',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -160,7 +169,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.GroupJoin',
|
'6': '.EncryptedContent.GroupJoin',
|
||||||
'9': 14,
|
'9': 15,
|
||||||
'10': 'groupJoin',
|
'10': 'groupJoin',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -170,7 +179,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.GroupUpdate',
|
'6': '.EncryptedContent.GroupUpdate',
|
||||||
'9': 15,
|
'9': 16,
|
||||||
'10': 'groupUpdate',
|
'10': 'groupUpdate',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -180,7 +189,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.ResendGroupPublicKey',
|
'6': '.EncryptedContent.ResendGroupPublicKey',
|
||||||
'9': 16,
|
'9': 17,
|
||||||
'10': 'resendGroupPublicKey',
|
'10': 'resendGroupPublicKey',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -190,7 +199,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.ErrorMessages',
|
'6': '.EncryptedContent.ErrorMessages',
|
||||||
'9': 17,
|
'9': 18,
|
||||||
'10': 'errorMessages',
|
'10': 'errorMessages',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -200,7 +209,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.AdditionalDataMessage',
|
'6': '.EncryptedContent.AdditionalDataMessage',
|
||||||
'9': 18,
|
'9': 19,
|
||||||
'10': 'additionalDataMessage',
|
'10': 'additionalDataMessage',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -210,7 +219,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.TypingIndicator',
|
'6': '.EncryptedContent.TypingIndicator',
|
||||||
'9': 19,
|
'9': 20,
|
||||||
'10': 'typingIndicator',
|
'10': 'typingIndicator',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -220,7 +229,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.UserDiscoveryRequest',
|
'6': '.EncryptedContent.UserDiscoveryRequest',
|
||||||
'9': 20,
|
'9': 21,
|
||||||
'10': 'userDiscoveryRequest',
|
'10': 'userDiscoveryRequest',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -230,7 +239,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.UserDiscoveryUpdate',
|
'6': '.EncryptedContent.UserDiscoveryUpdate',
|
||||||
'9': 21,
|
'9': 22,
|
||||||
'10': 'userDiscoveryUpdate',
|
'10': 'userDiscoveryUpdate',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -240,7 +249,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.KeyVerificationProof',
|
'6': '.EncryptedContent.KeyVerificationProof',
|
||||||
'9': 22,
|
'9': 23,
|
||||||
'10': 'keyVerificationProof',
|
'10': 'keyVerificationProof',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -250,7 +259,7 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.PasswordLessRecovery',
|
'6': '.EncryptedContent.PasswordLessRecovery',
|
||||||
'9': 23,
|
'9': 24,
|
||||||
'10': 'passwordlessRecovery',
|
'10': 'passwordlessRecovery',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -260,10 +269,20 @@ const EncryptedContent$json = {
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.EncryptedContent.PasswordLessRecoveryHeartbeat',
|
'6': '.EncryptedContent.PasswordLessRecoveryHeartbeat',
|
||||||
'9': 24,
|
'9': 25,
|
||||||
'10': 'passwordlessRecoveryHeartbeat',
|
'10': 'passwordlessRecoveryHeartbeat',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'1': 'story',
|
||||||
|
'3': 30,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.EncryptedContent.Story',
|
||||||
|
'9': 26,
|
||||||
|
'10': 'story',
|
||||||
|
'17': true
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'3': [
|
'3': [
|
||||||
EncryptedContent_ErrorMessages$json,
|
EncryptedContent_ErrorMessages$json,
|
||||||
|
|
@ -276,6 +295,7 @@ const EncryptedContent$json = {
|
||||||
EncryptedContent_Reaction$json,
|
EncryptedContent_Reaction$json,
|
||||||
EncryptedContent_MessageUpdate$json,
|
EncryptedContent_MessageUpdate$json,
|
||||||
EncryptedContent_Media$json,
|
EncryptedContent_Media$json,
|
||||||
|
EncryptedContent_Story$json,
|
||||||
EncryptedContent_MediaUpdate$json,
|
EncryptedContent_MediaUpdate$json,
|
||||||
EncryptedContent_ContactRequest$json,
|
EncryptedContent_ContactRequest$json,
|
||||||
EncryptedContent_ContactUpdate$json,
|
EncryptedContent_ContactUpdate$json,
|
||||||
|
|
@ -293,6 +313,7 @@ const EncryptedContent$json = {
|
||||||
{'1': '_sender_profile_counter'},
|
{'1': '_sender_profile_counter'},
|
||||||
{'1': '_sender_user_discovery_version'},
|
{'1': '_sender_user_discovery_version'},
|
||||||
{'1': '_ask_for_friend_promotions'},
|
{'1': '_ask_for_friend_promotions'},
|
||||||
|
{'1': '_widget_sharing_allowed'},
|
||||||
{'1': '_message_update'},
|
{'1': '_message_update'},
|
||||||
{'1': '_media'},
|
{'1': '_media'},
|
||||||
{'1': '_media_update'},
|
{'1': '_media_update'},
|
||||||
|
|
@ -313,6 +334,10 @@ const EncryptedContent$json = {
|
||||||
{'1': '_key_verification_proof'},
|
{'1': '_key_verification_proof'},
|
||||||
{'1': '_passwordless_recovery'},
|
{'1': '_passwordless_recovery'},
|
||||||
{'1': '_passwordless_recovery_heartbeat'},
|
{'1': '_passwordless_recovery_heartbeat'},
|
||||||
|
{'1': '_story'},
|
||||||
|
],
|
||||||
|
'9': [
|
||||||
|
{'1': 28, '2': 29},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -440,9 +465,19 @@ const EncryptedContent_TextMessage$json = {
|
||||||
'10': 'quoteMessageId',
|
'10': 'quoteMessageId',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'1': 'additional_message_data',
|
||||||
|
'3': 5,
|
||||||
|
'4': 1,
|
||||||
|
'5': 12,
|
||||||
|
'9': 1,
|
||||||
|
'10': 'additionalMessageData',
|
||||||
|
'17': true
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'8': [
|
'8': [
|
||||||
{'1': '_quote_message_id'},
|
{'1': '_quote_message_id'},
|
||||||
|
{'1': '_additional_message_data'},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -462,6 +497,7 @@ const EncryptedContent_AdditionalDataMessage$json = {
|
||||||
'10': 'additionalMessageData',
|
'10': 'additionalMessageData',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
{'1': 'hidden', '3': 5, '4': 1, '5': 8, '10': 'hidden'},
|
||||||
],
|
],
|
||||||
'8': [
|
'8': [
|
||||||
{'1': '_additional_message_data'},
|
{'1': '_additional_message_data'},
|
||||||
|
|
@ -610,6 +646,15 @@ const EncryptedContent_Media$json = {
|
||||||
'10': 'additionalMessageData',
|
'10': 'additionalMessageData',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'1': 'widget_only',
|
||||||
|
'3': 12,
|
||||||
|
'4': 1,
|
||||||
|
'5': 8,
|
||||||
|
'9': 7,
|
||||||
|
'10': 'widgetOnly',
|
||||||
|
'17': true
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'4': [EncryptedContent_Media_Type$json],
|
'4': [EncryptedContent_Media_Type$json],
|
||||||
'8': [
|
'8': [
|
||||||
|
|
@ -620,6 +665,7 @@ const EncryptedContent_Media$json = {
|
||||||
{'1': '_encryption_mac'},
|
{'1': '_encryption_mac'},
|
||||||
{'1': '_encryption_nonce'},
|
{'1': '_encryption_nonce'},
|
||||||
{'1': '_additional_message_data'},
|
{'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')
|
@$core.Deprecated('Use encryptedContentDescriptor instead')
|
||||||
const EncryptedContent_MediaUpdate$json = {
|
const EncryptedContent_MediaUpdate$json = {
|
||||||
'1': 'MediaUpdate',
|
'1': 'MediaUpdate',
|
||||||
|
|
@ -831,36 +893,38 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
|
||||||
'X2NvdW50ZXIYBCABKANIAlIUc2VuZGVyUHJvZmlsZUNvdW50ZXKIAQESRgodc2VuZGVyX3VzZX'
|
'X2NvdW50ZXIYBCABKANIAlIUc2VuZGVyUHJvZmlsZUNvdW50ZXKIAQESRgodc2VuZGVyX3VzZX'
|
||||||
'JfZGlzY292ZXJ5X3ZlcnNpb24YFSABKAxIA1Iac2VuZGVyVXNlckRpc2NvdmVyeVZlcnNpb26I'
|
'JfZGlzY292ZXJ5X3ZlcnNpb24YFSABKAxIA1Iac2VuZGVyVXNlckRpc2NvdmVyeVZlcnNpb26I'
|
||||||
'AQESPgoZYXNrX2Zvcl9mcmllbmRfcHJvbW90aW9ucxgZIAEoCEgEUhZhc2tGb3JGcmllbmRQcm'
|
'AQESPgoZYXNrX2Zvcl9mcmllbmRfcHJvbW90aW9ucxgZIAEoCEgEUhZhc2tGb3JGcmllbmRQcm'
|
||||||
'9tb3Rpb25ziAEBEksKDm1lc3NhZ2VfdXBkYXRlGAUgASgLMh8uRW5jcnlwdGVkQ29udGVudC5N'
|
'9tb3Rpb25ziAEBEjkKFndpZGdldF9zaGFyaW5nX2FsbG93ZWQYHSABKAhIBVIUd2lkZ2V0U2hh'
|
||||||
'ZXNzYWdlVXBkYXRlSAVSDW1lc3NhZ2VVcGRhdGWIAQESMgoFbWVkaWEYBiABKAsyFy5FbmNyeX'
|
'cmluZ0FsbG93ZWSIAQESSwoObWVzc2FnZV91cGRhdGUYBSABKAsyHy5FbmNyeXB0ZWRDb250ZW'
|
||||||
'B0ZWRDb250ZW50Lk1lZGlhSAZSBW1lZGlhiAEBEkUKDG1lZGlhX3VwZGF0ZRgHIAEoCzIdLkVu'
|
'50Lk1lc3NhZ2VVcGRhdGVIBlINbWVzc2FnZVVwZGF0ZYgBARIyCgVtZWRpYRgGIAEoCzIXLkVu'
|
||||||
'Y3J5cHRlZENvbnRlbnQuTWVkaWFVcGRhdGVIB1ILbWVkaWFVcGRhdGWIAQESSwoOY29udGFjdF'
|
'Y3J5cHRlZENvbnRlbnQuTWVkaWFIB1IFbWVkaWGIAQESRQoMbWVkaWFfdXBkYXRlGAcgASgLMh'
|
||||||
'91cGRhdGUYCCABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGVICFINY29udGFj'
|
'0uRW5jcnlwdGVkQ29udGVudC5NZWRpYVVwZGF0ZUgIUgttZWRpYVVwZGF0ZYgBARJLCg5jb250'
|
||||||
'dFVwZGF0ZYgBARJOCg9jb250YWN0X3JlcXVlc3QYCSABKAsyIC5FbmNyeXB0ZWRDb250ZW50Lk'
|
'YWN0X3VwZGF0ZRgIIAEoCzIfLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFVwZGF0ZUgJUg1jb2'
|
||||||
'NvbnRhY3RSZXF1ZXN0SAlSDmNvbnRhY3RSZXF1ZXN0iAEBEj8KCmZsYW1lX3N5bmMYCiABKAsy'
|
'50YWN0VXBkYXRliAEBEk4KD2NvbnRhY3RfcmVxdWVzdBgJIAEoCzIgLkVuY3J5cHRlZENvbnRl'
|
||||||
'Gy5FbmNyeXB0ZWRDb250ZW50LkZsYW1lU3luY0gKUglmbGFtZVN5bmOIAQESOwoIcmVhY3Rpb2'
|
'bnQuQ29udGFjdFJlcXVlc3RIClIOY29udGFjdFJlcXVlc3SIAQESPwoKZmxhbWVfc3luYxgKIA'
|
||||||
'4YDCABKAsyGi5FbmNyeXB0ZWRDb250ZW50LlJlYWN0aW9uSAtSCHJlYWN0aW9uiAEBEkUKDHRl'
|
'EoCzIbLkVuY3J5cHRlZENvbnRlbnQuRmxhbWVTeW5jSAtSCWZsYW1lU3luY4gBARI7CghyZWFj'
|
||||||
'eHRfbWVzc2FnZRgNIAEoCzIdLkVuY3J5cHRlZENvbnRlbnQuVGV4dE1lc3NhZ2VIDFILdGV4dE'
|
'dGlvbhgMIAEoCzIaLkVuY3J5cHRlZENvbnRlbnQuUmVhY3Rpb25IDFIIcmVhY3Rpb26IAQESRQ'
|
||||||
'1lc3NhZ2WIAQESRQoMZ3JvdXBfY3JlYXRlGA4gASgLMh0uRW5jcnlwdGVkQ29udGVudC5Hcm91'
|
'oMdGV4dF9tZXNzYWdlGA0gASgLMh0uRW5jcnlwdGVkQ29udGVudC5UZXh0TWVzc2FnZUgNUgt0'
|
||||||
'cENyZWF0ZUgNUgtncm91cENyZWF0ZYgBARI/Cgpncm91cF9qb2luGA8gASgLMhsuRW5jcnlwdG'
|
'ZXh0TWVzc2FnZYgBARJFCgxncm91cF9jcmVhdGUYDiABKAsyHS5FbmNyeXB0ZWRDb250ZW50Lk'
|
||||||
'VkQ29udGVudC5Hcm91cEpvaW5IDlIJZ3JvdXBKb2luiAEBEkUKDGdyb3VwX3VwZGF0ZRgQIAEo'
|
'dyb3VwQ3JlYXRlSA5SC2dyb3VwQ3JlYXRliAEBEj8KCmdyb3VwX2pvaW4YDyABKAsyGy5FbmNy'
|
||||||
'CzIdLkVuY3J5cHRlZENvbnRlbnQuR3JvdXBVcGRhdGVID1ILZ3JvdXBVcGRhdGWIAQESYgoXcm'
|
'eXB0ZWRDb250ZW50Lkdyb3VwSm9pbkgPUglncm91cEpvaW6IAQESRQoMZ3JvdXBfdXBkYXRlGB'
|
||||||
'VzZW5kX2dyb3VwX3B1YmxpY19rZXkYESABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlJlc2VuZEdy'
|
'AgASgLMh0uRW5jcnlwdGVkQ29udGVudC5Hcm91cFVwZGF0ZUgQUgtncm91cFVwZGF0ZYgBARJi'
|
||||||
'b3VwUHVibGljS2V5SBBSFHJlc2VuZEdyb3VwUHVibGljS2V5iAEBEksKDmVycm9yX21lc3NhZ2'
|
'ChdyZXNlbmRfZ3JvdXBfcHVibGljX2tleRgRIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuUmVzZW'
|
||||||
'VzGBIgASgLMh8uRW5jcnlwdGVkQ29udGVudC5FcnJvck1lc3NhZ2VzSBFSDWVycm9yTWVzc2Fn'
|
'5kR3JvdXBQdWJsaWNLZXlIEVIUcmVzZW5kR3JvdXBQdWJsaWNLZXmIAQESSwoOZXJyb3JfbWVz'
|
||||||
'ZXOIAQESZAoXYWRkaXRpb25hbF9kYXRhX21lc3NhZ2UYEyABKAsyJy5FbmNyeXB0ZWRDb250ZW'
|
'c2FnZXMYEiABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXNIElINZXJyb3JNZX'
|
||||||
'50LkFkZGl0aW9uYWxEYXRhTWVzc2FnZUgSUhVhZGRpdGlvbmFsRGF0YU1lc3NhZ2WIAQESUQoQ'
|
'NzYWdlc4gBARJkChdhZGRpdGlvbmFsX2RhdGFfbWVzc2FnZRgTIAEoCzInLkVuY3J5cHRlZENv'
|
||||||
'dHlwaW5nX2luZGljYXRvchgUIAEoCzIhLkVuY3J5cHRlZENvbnRlbnQuVHlwaW5nSW5kaWNhdG'
|
'bnRlbnQuQWRkaXRpb25hbERhdGFNZXNzYWdlSBNSFWFkZGl0aW9uYWxEYXRhTWVzc2FnZYgBAR'
|
||||||
'9ySBNSD3R5cGluZ0luZGljYXRvcogBARJhChZ1c2VyX2Rpc2NvdmVyeV9yZXF1ZXN0GBYgASgL'
|
'JRChB0eXBpbmdfaW5kaWNhdG9yGBQgASgLMiEuRW5jcnlwdGVkQ29udGVudC5UeXBpbmdJbmRp'
|
||||||
'MiYuRW5jcnlwdGVkQ29udGVudC5Vc2VyRGlzY292ZXJ5UmVxdWVzdEgUUhR1c2VyRGlzY292ZX'
|
'Y2F0b3JIFFIPdHlwaW5nSW5kaWNhdG9yiAEBEmEKFnVzZXJfZGlzY292ZXJ5X3JlcXVlc3QYFi'
|
||||||
'J5UmVxdWVzdIgBARJeChV1c2VyX2Rpc2NvdmVyeV91cGRhdGUYFyABKAsyJS5FbmNyeXB0ZWRD'
|
'ABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlVzZXJEaXNjb3ZlcnlSZXF1ZXN0SBVSFHVzZXJEaXNj'
|
||||||
'b250ZW50LlVzZXJEaXNjb3ZlcnlVcGRhdGVIFVITdXNlckRpc2NvdmVyeVVwZGF0ZYgBARJhCh'
|
'b3ZlcnlSZXF1ZXN0iAEBEl4KFXVzZXJfZGlzY292ZXJ5X3VwZGF0ZRgXIAEoCzIlLkVuY3J5cH'
|
||||||
'ZrZXlfdmVyaWZpY2F0aW9uX3Byb29mGBggASgLMiYuRW5jcnlwdGVkQ29udGVudC5LZXlWZXJp'
|
'RlZENvbnRlbnQuVXNlckRpc2NvdmVyeVVwZGF0ZUgWUhN1c2VyRGlzY292ZXJ5VXBkYXRliAEB'
|
||||||
'ZmljYXRpb25Qcm9vZkgWUhRrZXlWZXJpZmljYXRpb25Qcm9vZogBARJgChVwYXNzd29yZGxlc3'
|
'EmEKFmtleV92ZXJpZmljYXRpb25fcHJvb2YYGCABKAsyJi5FbmNyeXB0ZWRDb250ZW50LktleV'
|
||||||
'NfcmVjb3ZlcnkYGiABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlBhc3N3b3JkTGVzc1JlY292ZXJ5'
|
'ZlcmlmaWNhdGlvblByb29mSBdSFGtleVZlcmlmaWNhdGlvblByb29miAEBEmAKFXBhc3N3b3Jk'
|
||||||
'SBdSFHBhc3N3b3JkbGVzc1JlY292ZXJ5iAEBEnwKH3Bhc3N3b3JkbGVzc19yZWNvdmVyeV9oZW'
|
'bGVzc19yZWNvdmVyeRgaIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuUGFzc3dvcmRMZXNzUmVjb3'
|
||||||
'FydGJlYXQYGyABKAsyLy5FbmNyeXB0ZWRDb250ZW50LlBhc3N3b3JkTGVzc1JlY292ZXJ5SGVh'
|
'ZlcnlIGFIUcGFzc3dvcmRsZXNzUmVjb3ZlcnmIAQESfAofcGFzc3dvcmRsZXNzX3JlY292ZXJ5'
|
||||||
'cnRiZWF0SBhSHXBhc3N3b3JkbGVzc1JlY292ZXJ5SGVhcnRiZWF0iAEBGpYCCg1FcnJvck1lc3'
|
'X2hlYXJ0YmVhdBgbIAEoCzIvLkVuY3J5cHRlZENvbnRlbnQuUGFzc3dvcmRMZXNzUmVjb3Zlcn'
|
||||||
|
'lIZWFydGJlYXRIGVIdcGFzc3dvcmRsZXNzUmVjb3ZlcnlIZWFydGJlYXSIAQESMgoFc3RvcnkY'
|
||||||
|
'HiABKAsyFy5FbmNyeXB0ZWRDb250ZW50LlN0b3J5SBpSBXN0b3J5iAEBGpYCCg1FcnJvck1lc3'
|
||||||
'NhZ2VzEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXMuVHlw'
|
'NhZ2VzEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXMuVHlw'
|
||||||
'ZVIEdHlwZRIsChJyZWxhdGVkX3JlY2VpcHRfaWQYAiABKAlSEHJlbGF0ZWRSZWNlaXB0SWQinA'
|
'ZVIEdHlwZRIsChJyZWxhdGVkX3JlY2VpcHRfaWQYAiABKAlSEHJlbGF0ZWRSZWNlaXB0SWQinA'
|
||||||
'EKBFR5cGUSPAo4RVJST1JfUFJPQ0VTU0lOR19NRVNTQUdFX0NSRUFURURfQUNDT1VOVF9SRVFV'
|
'EKBFR5cGUSPAo4RVJST1JfUFJPQ0VTU0lOR19NRVNTQUdFX0NSRUFURURfQUNDT1VOVF9SRVFV'
|
||||||
|
|
@ -875,65 +939,70 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
|
||||||
'X25hbWUYAyABKAlIAVIMbmV3R3JvdXBOYW1liAEBElcKJm5ld19kZWxldGVfbWVzc2FnZXNfYW'
|
'X25hbWUYAyABKAlIAVIMbmV3R3JvdXBOYW1liAEBElcKJm5ld19kZWxldGVfbWVzc2FnZXNfYW'
|
||||||
'Z0ZXJfbWlsbGlzZWNvbmRzGAQgASgDSAJSIm5ld0RlbGV0ZU1lc3NhZ2VzQWZ0ZXJNaWxsaXNl'
|
'Z0ZXJfbWlsbGlzZWNvbmRzGAQgASgDSAJSIm5ld0RlbGV0ZU1lc3NhZ2VzQWZ0ZXJNaWxsaXNl'
|
||||||
'Y29uZHOIAQFCFgoUX2FmZmVjdGVkX2NvbnRhY3RfaWRCEQoPX25ld19ncm91cF9uYW1lQikKJ1'
|
'Y29uZHOIAQFCFgoUX2FmZmVjdGVkX2NvbnRhY3RfaWRCEQoPX25ld19ncm91cF9uYW1lQikKJ1'
|
||||||
'9uZXdfZGVsZXRlX21lc3NhZ2VzX2FmdGVyX21pbGxpc2Vjb25kcxqvAQoLVGV4dE1lc3NhZ2US'
|
'9uZXdfZGVsZXRlX21lc3NhZ2VzX2FmdGVyX21pbGxpc2Vjb25kcxqIAgoLVGV4dE1lc3NhZ2US'
|
||||||
'KgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3NlbmRlck1lc3NhZ2VJZBISCgR0ZXh0GAIgAS'
|
'KgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3NlbmRlck1lc3NhZ2VJZBISCgR0ZXh0GAIgAS'
|
||||||
'gJUgR0ZXh0EhwKCXRpbWVzdGFtcBgDIAEoA1IJdGltZXN0YW1wEi0KEHF1b3RlX21lc3NhZ2Vf'
|
'gJUgR0ZXh0EhwKCXRpbWVzdGFtcBgDIAEoA1IJdGltZXN0YW1wEi0KEHF1b3RlX21lc3NhZ2Vf'
|
||||||
'aWQYBCABKAlIAFIOcXVvdGVNZXNzYWdlSWSIAQFCEwoRX3F1b3RlX21lc3NhZ2VfaWQazgEKFU'
|
'aWQYBCABKAlIAFIOcXVvdGVNZXNzYWdlSWSIAQESOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdG'
|
||||||
'FkZGl0aW9uYWxEYXRhTWVzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2VuZGVy'
|
'EYBSABKAxIAVIVYWRkaXRpb25hbE1lc3NhZ2VEYXRhiAEBQhMKEV9xdW90ZV9tZXNzYWdlX2lk'
|
||||||
'TWVzc2FnZUlkEhwKCXRpbWVzdGFtcBgCIAEoA1IJdGltZXN0YW1wEhIKBHR5cGUYAyABKAlSBH'
|
'QhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRrmAQoVQWRkaXRpb25hbERhdGFNZXNzYWdlEi'
|
||||||
'R5cGUSOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYBCABKAxIAFIVYWRkaXRpb25hbE1lc3Nh'
|
'oKEXNlbmRlcl9tZXNzYWdlX2lkGAEgASgJUg9zZW5kZXJNZXNzYWdlSWQSHAoJdGltZXN0YW1w'
|
||||||
'Z2VEYXRhiAEBQhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRpkCghSZWFjdGlvbhIqChF0YX'
|
'GAIgASgDUgl0aW1lc3RhbXASEgoEdHlwZRgDIAEoCVIEdHlwZRI7ChdhZGRpdGlvbmFsX21lc3'
|
||||||
'JnZXRfbWVzc2FnZV9pZBgBIAEoCVIPdGFyZ2V0TWVzc2FnZUlkEhQKBWVtb2ppGAIgASgJUgVl'
|
'NhZ2VfZGF0YRgEIAEoDEgAUhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQESFgoGaGlkZGVuGAUg'
|
||||||
'bW9qaRIWCgZyZW1vdmUYAyABKAhSBnJlbW92ZRq+AgoNTWVzc2FnZVVwZGF0ZRI4CgR0eXBlGA'
|
'ASgIUgZoaWRkZW5CGgoYX2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGmQKCFJlYWN0aW9uEioKEX'
|
||||||
'EgASgOMiQuRW5jcnlwdGVkQ29udGVudC5NZXNzYWdlVXBkYXRlLlR5cGVSBHR5cGUSLwoRc2Vu'
|
'RhcmdldF9tZXNzYWdlX2lkGAEgASgJUg90YXJnZXRNZXNzYWdlSWQSFAoFZW1vamkYAiABKAlS'
|
||||||
'ZGVyX21lc3NhZ2VfaWQYAiABKAlIAFIPc2VuZGVyTWVzc2FnZUlkiAEBEj0KG211bHRpcGxlX3'
|
'BWVtb2ppEhYKBnJlbW92ZRgDIAEoCFIGcmVtb3ZlGr4CCg1NZXNzYWdlVXBkYXRlEjgKBHR5cG'
|
||||||
'RhcmdldF9tZXNzYWdlX2lkcxgDIAMoCVIYbXVsdGlwbGVUYXJnZXRNZXNzYWdlSWRzEhcKBHRl'
|
'UYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50Lk1lc3NhZ2VVcGRhdGUuVHlwZVIEdHlwZRIvChFz'
|
||||||
'eHQYBCABKAlIAVIEdGV4dIgBARIcCgl0aW1lc3RhbXAYBSABKANSCXRpbWVzdGFtcCItCgRUeX'
|
'ZW5kZXJfbWVzc2FnZV9pZBgCIAEoCUgAUg9zZW5kZXJNZXNzYWdlSWSIAQESPQobbXVsdGlwbG'
|
||||||
'BlEgoKBkRFTEVURRAAEg0KCUVESVRfVEVYVBABEgoKBk9QRU5FRBACQhQKEl9zZW5kZXJfbWVz'
|
'VfdGFyZ2V0X21lc3NhZ2VfaWRzGAMgAygJUhhtdWx0aXBsZVRhcmdldE1lc3NhZ2VJZHMSFwoE'
|
||||||
'c2FnZV9pZEIHCgVfdGV4dBqFBgoFTWVkaWESKgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3'
|
'dGV4dBgEIAEoCUgBUgR0ZXh0iAEBEhwKCXRpbWVzdGFtcBgFIAEoA1IJdGltZXN0YW1wIi0KBF'
|
||||||
'NlbmRlck1lc3NhZ2VJZBIwCgR0eXBlGAIgASgOMhwuRW5jcnlwdGVkQ29udGVudC5NZWRpYS5U'
|
'R5cGUSCgoGREVMRVRFEAASDQoJRURJVF9URVhUEAESCgoGT1BFTkVEEAJCFAoSX3NlbmRlcl9t'
|
||||||
'eXBlUgR0eXBlEkYKHWRpc3BsYXlfbGltaXRfaW5fbWlsbGlzZWNvbmRzGAMgASgDSABSGmRpc3'
|
'ZXNzYWdlX2lkQgcKBV90ZXh0GrsGCgVNZWRpYRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCV'
|
||||||
'BsYXlMaW1pdEluTWlsbGlzZWNvbmRziAEBEjcKF3JlcXVpcmVzX2F1dGhlbnRpY2F0aW9uGAQg'
|
'IPc2VuZGVyTWVzc2FnZUlkEjAKBHR5cGUYAiABKA4yHC5FbmNyeXB0ZWRDb250ZW50Lk1lZGlh'
|
||||||
'ASgIUhZyZXF1aXJlc0F1dGhlbnRpY2F0aW9uEhwKCXRpbWVzdGFtcBgFIAEoA1IJdGltZXN0YW'
|
'LlR5cGVSBHR5cGUSRgodZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY29uZHMYAyABKANIAFIaZG'
|
||||||
'1wEi0KEHF1b3RlX21lc3NhZ2VfaWQYBiABKAlIAVIOcXVvdGVNZXNzYWdlSWSIAQESKgoOZG93'
|
'lzcGxheUxpbWl0SW5NaWxsaXNlY29uZHOIAQESNwoXcmVxdWlyZXNfYXV0aGVudGljYXRpb24Y'
|
||||||
'bmxvYWRfdG9rZW4YByABKAxIAlINZG93bmxvYWRUb2tlbogBARIqCg5lbmNyeXB0aW9uX2tleR'
|
'BCABKAhSFnJlcXVpcmVzQXV0aGVudGljYXRpb24SHAoJdGltZXN0YW1wGAUgASgDUgl0aW1lc3'
|
||||||
'gIIAEoDEgDUg1lbmNyeXB0aW9uS2V5iAEBEioKDmVuY3J5cHRpb25fbWFjGAkgASgMSARSDWVu'
|
'RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZBgGIAEoCUgBUg5xdW90ZU1lc3NhZ2VJZIgBARIqCg5k'
|
||||||
'Y3J5cHRpb25NYWOIAQESLgoQZW5jcnlwdGlvbl9ub25jZRgKIAEoDEgFUg9lbmNyeXB0aW9uTm'
|
'b3dubG9hZF90b2tlbhgHIAEoDEgCUg1kb3dubG9hZFRva2VuiAEBEioKDmVuY3J5cHRpb25fa2'
|
||||||
'9uY2WIAQESOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYCyABKAxIBlIVYWRkaXRpb25hbE1l'
|
'V5GAggASgMSANSDWVuY3J5cHRpb25LZXmIAQESKgoOZW5jcnlwdGlvbl9tYWMYCSABKAxIBFIN'
|
||||||
'c3NhZ2VEYXRhiAEBIj4KBFR5cGUSDAoIUkVVUExPQUQQABIJCgVJTUFHRRABEgkKBVZJREVPEA'
|
'ZW5jcnlwdGlvbk1hY4gBARIuChBlbmNyeXB0aW9uX25vbmNlGAogASgMSAVSD2VuY3J5cHRpb2'
|
||||||
'ISBwoDR0lGEAMSCQoFQVVESU8QBEIgCh5fZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY29uZHNC'
|
'5Ob25jZYgBARI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgLIAEoDEgGUhVhZGRpdGlvbmFs'
|
||||||
'EwoRX3F1b3RlX21lc3NhZ2VfaWRCEQoPX2Rvd25sb2FkX3Rva2VuQhEKD19lbmNyeXB0aW9uX2'
|
'TWVzc2FnZURhdGGIAQESJAoLd2lkZ2V0X29ubHkYDCABKAhIB1IKd2lkZ2V0T25seYgBASI+Cg'
|
||||||
'tleUIRCg9fZW5jcnlwdGlvbl9tYWNCEwoRX2VuY3J5cHRpb25fbm9uY2VCGgoYX2FkZGl0aW9u'
|
'RUeXBlEgwKCFJFVVBMT0FEEAASCQoFSU1BR0UQARIJCgVWSURFTxACEgcKA0dJRhADEgkKBUFV'
|
||||||
'YWxfbWVzc2FnZV9kYXRhGqkBCgtNZWRpYVVwZGF0ZRI2CgR0eXBlGAEgASgOMiIuRW5jcnlwdG'
|
'RElPEARCIAoeX2Rpc3BsYXlfbGltaXRfaW5fbWlsbGlzZWNvbmRzQhMKEV9xdW90ZV9tZXNzYW'
|
||||||
'VkQ29udGVudC5NZWRpYVVwZGF0ZS5UeXBlUgR0eXBlEioKEXRhcmdldF9tZXNzYWdlX2lkGAIg'
|
'dlX2lkQhEKD19kb3dubG9hZF90b2tlbkIRCg9fZW5jcnlwdGlvbl9rZXlCEQoPX2VuY3J5cHRp'
|
||||||
'ASgJUg90YXJnZXRNZXNzYWdlSWQiNgoEVHlwZRIMCghSRU9QRU5FRBAAEgoKBlNUT1JFRBABEh'
|
'b25fbWFjQhMKEV9lbmNyeXB0aW9uX25vbmNlQhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YU'
|
||||||
'QKEERFQ1JZUFRJT05fRVJST1IQAhp4Cg5Db250YWN0UmVxdWVzdBI5CgR0eXBlGAEgASgOMiUu'
|
'IOCgxfd2lkZ2V0X29ubHkaTgoFU3RvcnkSLQoFbWVkaWEYASABKAsyFy5FbmNyeXB0ZWRDb250'
|
||||||
'RW5jcnlwdGVkQ29udGVudC5Db250YWN0UmVxdWVzdC5UeXBlUgR0eXBlIisKBFR5cGUSCwoHUk'
|
'ZW50Lk1lZGlhUgVtZWRpYRIWCgZub3RpZnkYAiABKAhSBm5vdGlmeRqpAQoLTWVkaWFVcGRhdG'
|
||||||
'VRVUVTVBAAEgoKBlJFSkVDVBABEgoKBkFDQ0VQVBACGqQCCg1Db250YWN0VXBkYXRlEjgKBHR5'
|
'USNgoEdHlwZRgBIAEoDjIiLkVuY3J5cHRlZENvbnRlbnQuTWVkaWFVcGRhdGUuVHlwZVIEdHlw'
|
||||||
'cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGUuVHlwZVIEdHlwZRI3Ch'
|
'ZRIqChF0YXJnZXRfbWVzc2FnZV9pZBgCIAEoCVIPdGFyZ2V0TWVzc2FnZUlkIjYKBFR5cGUSDA'
|
||||||
'VhdmF0YXJfc3ZnX2NvbXByZXNzZWQYAiABKAxIAFITYXZhdGFyU3ZnQ29tcHJlc3NlZIgBARIf'
|
'oIUkVPUEVORUQQABIKCgZTVE9SRUQQARIUChBERUNSWVBUSU9OX0VSUk9SEAIaeAoOQ29udGFj'
|
||||||
'Cgh1c2VybmFtZRgDIAEoCUgBUgh1c2VybmFtZYgBARImCgxkaXNwbGF5X25hbWUYBCABKAlIAl'
|
'dFJlcXVlc3QSOQoEdHlwZRgBIAEoDjIlLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFJlcXVlc3'
|
||||||
'ILZGlzcGxheU5hbWWIAQEiHwoEVHlwZRILCgdSRVFVRVNUEAASCgoGVVBEQVRFEAFCGAoWX2F2'
|
'QuVHlwZVIEdHlwZSIrCgRUeXBlEgsKB1JFUVVFU1QQABIKCgZSRUpFQ1QQARIKCgZBQ0NFUFQQ'
|
||||||
'YXRhcl9zdmdfY29tcHJlc3NlZEILCglfdXNlcm5hbWVCDwoNX2Rpc3BsYXlfbmFtZRqvAQoJRm'
|
'AhqkAgoNQ29udGFjdFVwZGF0ZRI4CgR0eXBlGAEgASgOMiQuRW5jcnlwdGVkQ29udGVudC5Db2'
|
||||||
'xhbWVTeW5jEiMKDWZsYW1lX2NvdW50ZXIYASABKANSDGZsYW1lQ291bnRlchI5ChlsYXN0X2Zs'
|
'50YWN0VXBkYXRlLlR5cGVSBHR5cGUSNwoVYXZhdGFyX3N2Z19jb21wcmVzc2VkGAIgASgMSABS'
|
||||||
'YW1lX2NvdW50ZXJfY2hhbmdlGAIgASgDUhZsYXN0RmxhbWVDb3VudGVyQ2hhbmdlEh8KC2Jlc3'
|
'E2F2YXRhclN2Z0NvbXByZXNzZWSIAQESHwoIdXNlcm5hbWUYAyABKAlIAVIIdXNlcm5hbWWIAQ'
|
||||||
'RfZnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiEKDGZvcmNlX3VwZGF0ZRgEIAEoCFILZm9yY2VV'
|
'ESJgoMZGlzcGxheV9uYW1lGAQgASgJSAJSC2Rpc3BsYXlOYW1liAEBIh8KBFR5cGUSCwoHUkVR'
|
||||||
'cGRhdGUaTQoPVHlwaW5nSW5kaWNhdG9yEhsKCWlzX3R5cGluZxgBIAEoCFIIaXNUeXBpbmcSHQ'
|
'VUVTVBAAEgoKBlVQREFURRABQhgKFl9hdmF0YXJfc3ZnX2NvbXByZXNzZWRCCwoJX3VzZXJuYW'
|
||||||
'oKY3JlYXRlZF9hdBgCIAEoA1IJY3JlYXRlZEF0Gj8KFFVzZXJEaXNjb3ZlcnlSZXF1ZXN0EicK'
|
'1lQg8KDV9kaXNwbGF5X25hbWUarwEKCUZsYW1lU3luYxIjCg1mbGFtZV9jb3VudGVyGAEgASgD'
|
||||||
'D2N1cnJlbnRfdmVyc2lvbhgBIAEoDFIOY3VycmVudFZlcnNpb24aMQoTVXNlckRpc2NvdmVyeV'
|
'UgxmbGFtZUNvdW50ZXISOQoZbGFzdF9mbGFtZV9jb3VudGVyX2NoYW5nZRgCIAEoA1IWbGFzdE'
|
||||||
'VwZGF0ZRIaCghtZXNzYWdlcxgBIAMoDFIIbWVzc2FnZXMaPQoUS2V5VmVyaWZpY2F0aW9uUHJv'
|
'ZsYW1lQ291bnRlckNoYW5nZRIfCgtiZXN0X2ZyaWVuZBgDIAEoCFIKYmVzdEZyaWVuZBIhCgxm'
|
||||||
'b2YSJQoOY2FsY3VsYXRlZF9tYWMYASABKAxSDWNhbGN1bGF0ZWRNYWMamwEKFFBhc3N3b3JkTG'
|
'b3JjZV91cGRhdGUYBCABKAhSC2ZvcmNlVXBkYXRlGk0KD1R5cGluZ0luZGljYXRvchIbCglpc1'
|
||||||
'Vzc1JlY292ZXJ5EjUKE3JlY292ZXJ5U2VjcmV0U2hhcmUYASABKAxIAFITcmVjb3ZlcnlTZWNy'
|
'90eXBpbmcYASABKAhSCGlzVHlwaW5nEh0KCmNyZWF0ZWRfYXQYAiABKANSCWNyZWF0ZWRBdBo/'
|
||||||
'ZXRTaGFyZYgBARIWCgZkZWxldGUYAiABKAhSBmRlbGV0ZRIcCgl0aHJlc2hvbGQYAyABKANSCX'
|
'ChRVc2VyRGlzY292ZXJ5UmVxdWVzdBInCg9jdXJyZW50X3ZlcnNpb24YASABKAxSDmN1cnJlbn'
|
||||||
'RocmVzaG9sZEIWChRfcmVjb3ZlcnlTZWNyZXRTaGFyZRozCh1QYXNzd29yZExlc3NSZWNvdmVy'
|
'RWZXJzaW9uGjEKE1VzZXJEaXNjb3ZlcnlVcGRhdGUSGgoIbWVzc2FnZXMYASADKAxSCG1lc3Nh'
|
||||||
'eUhlYXJ0YmVhdBISCgRoYXNoGAEgASgMUgRoYXNoQgsKCV9ncm91cF9pZEIRCg9faXNfZGlyZW'
|
'Z2VzGj0KFEtleVZlcmlmaWNhdGlvblByb29mEiUKDmNhbGN1bGF0ZWRfbWFjGAEgASgMUg1jYW'
|
||||||
'N0X2NoYXRCGQoXX3NlbmRlcl9wcm9maWxlX2NvdW50ZXJCIAoeX3NlbmRlcl91c2VyX2Rpc2Nv'
|
'xjdWxhdGVkTWFjGpsBChRQYXNzd29yZExlc3NSZWNvdmVyeRI1ChNyZWNvdmVyeVNlY3JldFNo'
|
||||||
'dmVyeV92ZXJzaW9uQhwKGl9hc2tfZm9yX2ZyaWVuZF9wcm9tb3Rpb25zQhEKD19tZXNzYWdlX3'
|
'YXJlGAEgASgMSABSE3JlY292ZXJ5U2VjcmV0U2hhcmWIAQESFgoGZGVsZXRlGAIgASgIUgZkZW'
|
||||||
'VwZGF0ZUIICgZfbWVkaWFCDwoNX21lZGlhX3VwZGF0ZUIRCg9fY29udGFjdF91cGRhdGVCEgoQ'
|
'xldGUSHAoJdGhyZXNob2xkGAMgASgDUgl0aHJlc2hvbGRCFgoUX3JlY292ZXJ5U2VjcmV0U2hh'
|
||||||
'X2NvbnRhY3RfcmVxdWVzdEINCgtfZmxhbWVfc3luY0ILCglfcmVhY3Rpb25CDwoNX3RleHRfbW'
|
'cmUaMwodUGFzc3dvcmRMZXNzUmVjb3ZlcnlIZWFydGJlYXQSEgoEaGFzaBgBIAEoDFIEaGFzaE'
|
||||||
'Vzc2FnZUIPCg1fZ3JvdXBfY3JlYXRlQg0KC19ncm91cF9qb2luQg8KDV9ncm91cF91cGRhdGVC'
|
'ILCglfZ3JvdXBfaWRCEQoPX2lzX2RpcmVjdF9jaGF0QhkKF19zZW5kZXJfcHJvZmlsZV9jb3Vu'
|
||||||
'GgoYX3Jlc2VuZF9ncm91cF9wdWJsaWNfa2V5QhEKD19lcnJvcl9tZXNzYWdlc0IaChhfYWRkaX'
|
'dGVyQiAKHl9zZW5kZXJfdXNlcl9kaXNjb3ZlcnlfdmVyc2lvbkIcChpfYXNrX2Zvcl9mcmllbm'
|
||||||
'Rpb25hbF9kYXRhX21lc3NhZ2VCEwoRX3R5cGluZ19pbmRpY2F0b3JCGQoXX3VzZXJfZGlzY292'
|
'RfcHJvbW90aW9uc0IZChdfd2lkZ2V0X3NoYXJpbmdfYWxsb3dlZEIRCg9fbWVzc2FnZV91cGRh'
|
||||||
'ZXJ5X3JlcXVlc3RCGAoWX3VzZXJfZGlzY292ZXJ5X3VwZGF0ZUIZChdfa2V5X3ZlcmlmaWNhdG'
|
'dGVCCAoGX21lZGlhQg8KDV9tZWRpYV91cGRhdGVCEQoPX2NvbnRhY3RfdXBkYXRlQhIKEF9jb2'
|
||||||
'lvbl9wcm9vZkIYChZfcGFzc3dvcmRsZXNzX3JlY292ZXJ5QiIKIF9wYXNzd29yZGxlc3NfcmVj'
|
'50YWN0X3JlcXVlc3RCDQoLX2ZsYW1lX3N5bmNCCwoJX3JlYWN0aW9uQg8KDV90ZXh0X21lc3Nh'
|
||||||
'b3ZlcnlfaGVhcnRiZWF0');
|
'Z2VCDwoNX2dyb3VwX2NyZWF0ZUINCgtfZ3JvdXBfam9pbkIPCg1fZ3JvdXBfdXBkYXRlQhoKGF'
|
||||||
|
'9yZXNlbmRfZ3JvdXBfcHVibGljX2tleUIRCg9fZXJyb3JfbWVzc2FnZXNCGgoYX2FkZGl0aW9u'
|
||||||
|
'YWxfZGF0YV9tZXNzYWdlQhMKEV90eXBpbmdfaW5kaWNhdG9yQhkKF191c2VyX2Rpc2NvdmVyeV'
|
||||||
|
'9yZXF1ZXN0QhgKFl91c2VyX2Rpc2NvdmVyeV91cGRhdGVCGQoXX2tleV92ZXJpZmljYXRpb25f'
|
||||||
|
'cHJvb2ZCGAoWX3Bhc3N3b3JkbGVzc19yZWNvdmVyeUIiCiBfcGFzc3dvcmRsZXNzX3JlY292ZX'
|
||||||
|
'J5X2hlYXJ0YmVhdEIICgZfc3RvcnlKBAgcEB0=');
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,24 @@ final routerProvider = GoRouter(
|
||||||
return MediaViewerView(group);
|
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(
|
GoRoute(
|
||||||
path: 'messages/:groupId',
|
path: 'messages/:groupId',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,16 @@ class NativeNotificationService {
|
||||||
/// Emits metadata for every native notification tapped while the app runs.
|
/// Emits metadata for every native notification tapped while the app runs.
|
||||||
static Stream<NativeNotificationTap> get taps => _taps.stream;
|
static Stream<NativeNotificationTap> get taps => _taps.stream;
|
||||||
|
|
||||||
/// Text messages (including quoted replies) and the announcements an app
|
/// Text messages (including quoted replies, and replies and reactions to
|
||||||
/// writes into a chat open their conversation. Media and every other
|
/// the user's story) and the announcements an app writes into a chat open
|
||||||
/// notification kind stay on the chat overview.
|
/// their conversation. Media and every other notification kind stay on the
|
||||||
|
/// chat overview.
|
||||||
static bool opensConversation(String? kind) =>
|
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() {
|
static void init() {
|
||||||
_startBadgeSync();
|
_startBadgeSync();
|
||||||
|
|
|
||||||
60
lib/src/visual/components/recipient_status_row.comp.dart
Normal file
60
lib/src/visual/components/recipient_status_row.comp.dart
Normal file
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
lib/src/visual/components/story_preview.comp.dart
Normal file
98
lib/src/visual/components/story_preview.comp.dart
Normal file
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/src/visual/elements/my_chip.element.dart
Normal file
68
lib/src/visual/elements/my_chip.element.dart
Normal file
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,25 +4,51 @@ import 'dart:collection';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.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/groups.dart' as rust_groups;
|
||||||
|
import 'package:twonly/core/bridge/stories.dart' as stories;
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/model/protobuf/client/generated/data.pb.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/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_request_badge.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/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/headline.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_button.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/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/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/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/camera/share_image_editor_components/layers/background.layer.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/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<String> selectedGroupIds,
|
||||||
|
required Set<String>? storyAudienceChats,
|
||||||
|
}) => {...selectedGroupIds, ...?storyAudienceChats}.length;
|
||||||
|
|
||||||
class ShareImageView extends StatefulWidget {
|
class ShareImageView extends StatefulWidget {
|
||||||
const ShareImageView({
|
const ShareImageView({
|
||||||
required this.selectedGroupIds,
|
required this.selectedGroupIds,
|
||||||
|
|
@ -31,6 +57,7 @@ class ShareImageView extends StatefulWidget {
|
||||||
required this.mediaFileService,
|
required this.mediaFileService,
|
||||||
required this.additionalData,
|
required this.additionalData,
|
||||||
required this.sendToWidget,
|
required this.sendToWidget,
|
||||||
|
required this.storyTarget,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final HashSet<String> selectedGroupIds;
|
final HashSet<String> selectedGroupIds;
|
||||||
|
|
@ -39,6 +66,7 @@ class ShareImageView extends StatefulWidget {
|
||||||
final MediaFileService mediaFileService;
|
final MediaFileService mediaFileService;
|
||||||
final AdditionalMessageData? additionalData;
|
final AdditionalMessageData? additionalData;
|
||||||
final bool sendToWidget;
|
final bool sendToWidget;
|
||||||
|
final StoryTarget storyTarget;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ShareImageView> createState() => _ShareImageView();
|
State<ShareImageView> createState() => _ShareImageView();
|
||||||
|
|
@ -57,6 +85,7 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
late StreamSubscription<List<Group>> allGroupSub;
|
late StreamSubscription<List<Group>> allGroupSub;
|
||||||
String lastQuery = '';
|
String lastQuery = '';
|
||||||
int _updateGroupsGeneration = 0;
|
int _updateGroupsGeneration = 0;
|
||||||
|
Set<String>? _storyAudienceChats;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -96,6 +125,7 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
unawaited(allGroupSub.cancel());
|
unawaited(allGroupSub.cancel());
|
||||||
|
searchUserName.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,6 +202,58 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
setState(() {});
|
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<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -195,22 +277,35 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
const EmptyChatListComp()
|
const EmptyChatListComp()
|
||||||
else ...[
|
else ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: TextField(
|
child: MyInput(
|
||||||
|
controller: searchUserName,
|
||||||
|
dense: true,
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
hintText: context.lang.shareImageSearchAllContacts,
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
onChanged: _filterUsers,
|
onChanged: _filterUsers,
|
||||||
decoration: getInputDecoration(
|
|
||||||
context,
|
|
||||||
context.lang.shareImageSearchAllContacts,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 10),
|
if (!widget.sendToWidget) ...[
|
||||||
if (!widget.sendToWidget)
|
|
||||||
ContactGroupShortcutRow(
|
ContactGroupShortcutRow(
|
||||||
selectedGroupIds: widget.selectedGroupIds,
|
selectedGroupIds: widget.selectedGroupIds,
|
||||||
updateSelectedGroupIds: updateSelectedGroupIds,
|
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(
|
BestFriendsSelector(
|
||||||
groups: _pinnedContacts,
|
groups: _pinnedContacts,
|
||||||
selectedGroupIds: widget.selectedGroupIds,
|
selectedGroupIds: widget.selectedGroupIds,
|
||||||
|
|
@ -220,7 +315,7 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
!widget.sendToWidget &&
|
!widget.sendToWidget &&
|
||||||
!widget.mediaFileService.mediaFile.requiresAuthentication,
|
!widget.mediaFileService.mediaFile.requiresAuthentication,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
if (_pinnedContacts.isNotEmpty) const SizedBox(height: 10),
|
||||||
BestFriendsSelector(
|
BestFriendsSelector(
|
||||||
groups: _bestFriends,
|
groups: _bestFriends,
|
||||||
selectedGroupIds: widget.selectedGroupIds,
|
selectedGroupIds: widget.selectedGroupIds,
|
||||||
|
|
@ -230,7 +325,7 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
!widget.sendToWidget &&
|
!widget.sendToWidget &&
|
||||||
!widget.mediaFileService.mediaFile.requiresAuthentication,
|
!widget.mediaFileService.mediaFile.requiresAuthentication,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
if (_bestFriends.isNotEmpty) const SizedBox(height: 10),
|
||||||
if (_otherUsers.isNotEmpty)
|
if (_otherUsers.isNotEmpty)
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|
@ -319,32 +414,17 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
MyButton(
|
MyButton(
|
||||||
variant: MyButtonVariant.primaryMiddle,
|
variant: MyButtonVariant.primaryMiddle,
|
||||||
onPressed:
|
onPressed:
|
||||||
!mediaStoreFutureReady ||
|
canSendSharedMedia(
|
||||||
widget.selectedGroupIds.isEmpty ||
|
mediaReady: mediaStoreFutureReady,
|
||||||
sendingImage
|
sending: sendingImage,
|
||||||
? null
|
hasChatRecipients:
|
||||||
: () async {
|
widget.selectedGroupIds.isNotEmpty,
|
||||||
setState(() {
|
storyPossible: _storyPossible,
|
||||||
sendingImage = true;
|
hasStoryTarget: !widget.storyTarget.isEmpty,
|
||||||
});
|
storyAudienceSize: _storyAudienceChats?.length,
|
||||||
|
)
|
||||||
// in case mediaStoreFutureReady is ready, the image is stored in the originalPath
|
? _send
|
||||||
unawaitedRustCall(
|
: null,
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -366,7 +446,7 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})',
|
'${context.lang.shareImagedEditorSendImage} ($_recipientCount)',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/misc.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';
|
import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart';
|
||||||
|
|
||||||
class ContactGroupShortcutRow extends StatefulWidget {
|
class ContactGroupShortcutRow extends StatefulWidget {
|
||||||
|
|
@ -24,6 +26,11 @@ class ContactGroupShortcutRow extends StatefulWidget {
|
||||||
|
|
||||||
class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
||||||
List<ContactGroup> _contactGroups = [];
|
List<ContactGroup> _contactGroups = [];
|
||||||
|
final List<int> _order = [];
|
||||||
|
|
||||||
|
/// The chats each shortcut selects, by contact group.
|
||||||
|
Map<int, Set<String>> _targets = {};
|
||||||
|
int _targetsGeneration = 0;
|
||||||
late final StreamSubscription<List<ContactGroup>> _subscription;
|
late final StreamSubscription<List<ContactGroup>> _subscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -32,7 +39,11 @@ class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
||||||
_subscription = twonlyDB.contactGroupsDao
|
_subscription = twonlyDB.contactGroupsDao
|
||||||
.watchShortcutContactGroups()
|
.watchShortcutContactGroups()
|
||||||
.listen((contactGroups) {
|
.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<ContactGroupShortcutRow> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openSettings([ContactGroup? contactGroup]) async {
|
Future<void> _loadTargets() async {
|
||||||
await context.navPush(
|
final generation = ++_targetsGeneration;
|
||||||
ContactGroupSettingsView(
|
final targets = {
|
||||||
contactGroup: contactGroup,
|
for (final contactGroup in _contactGroups)
|
||||||
initialShowAsShortcut: contactGroup == null,
|
contactGroup.id: await _resolveTargets(contactGroup),
|
||||||
),
|
};
|
||||||
);
|
if (!mounted || generation != _targetsGeneration) return;
|
||||||
|
setState(() => _targets = targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _apply(ContactGroup contactGroup) async {
|
Future<Set<String>> _resolveTargets(ContactGroup contactGroup) async {
|
||||||
await twonlyDB.contactGroupsDao.incrementUsage(contactGroup.id);
|
|
||||||
final members = await twonlyDB.contactGroupsDao.getMembers(contactGroup.id);
|
final members = await twonlyDB.contactGroupsDao.getMembers(contactGroup.id);
|
||||||
final targetGroupIds = <String>{};
|
final targetGroupIds = <String>{};
|
||||||
for (final member in members) {
|
for (final member in members) {
|
||||||
|
|
@ -65,6 +76,38 @@ class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
||||||
if (directChat != null) targetGroupIds.add(directChat.groupId);
|
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<String>? targets) =>
|
||||||
|
targets != null &&
|
||||||
|
targets.isNotEmpty &&
|
||||||
|
widget.selectedGroupIds.containsAll(targets);
|
||||||
|
|
||||||
|
Future<void> _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<void> _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()) {
|
for (final groupId in widget.selectedGroupIds.toList()) {
|
||||||
widget.updateSelectedGroupIds(groupId, false);
|
widget.updateSelectedGroupIds(groupId, false);
|
||||||
}
|
}
|
||||||
|
|
@ -76,37 +119,32 @@ class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 40,
|
height: 36,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
children: [
|
children: [
|
||||||
ActionChip(
|
MyChip(
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
tooltip: context.lang.createContactGroup,
|
tooltip: context.lang.createContactGroup,
|
||||||
onPressed: _openSettings,
|
onTap: _openSettings,
|
||||||
label: _contactGroups.isEmpty
|
label: _contactGroups.isEmpty
|
||||||
? Text(
|
? Text(context.lang.createContactGroup)
|
||||||
context.lang.createContactGroup,
|
: const Icon(Icons.add_reaction_outlined),
|
||||||
style: const TextStyle(fontSize: 9),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.add_reaction_outlined, size: 20),
|
|
||||||
shape: const StadiumBorder(),
|
|
||||||
),
|
),
|
||||||
for (final contactGroup in _contactGroups)
|
for (final contactGroup in _contactGroups) ...[
|
||||||
GestureDetector(
|
const SizedBox(width: 8),
|
||||||
onLongPress: () => _openSettings(contactGroup),
|
MyChip(
|
||||||
child: ActionChip(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
tooltip: contactGroup.name,
|
tooltip: contactGroup.name,
|
||||||
onPressed: () => _apply(contactGroup),
|
selected: _isSelected(_targets[contactGroup.id]),
|
||||||
|
onTap: () => _apply(contactGroup),
|
||||||
|
onLongPress: () => _openSettings(contactGroup),
|
||||||
label: Text(
|
label: Text(
|
||||||
contactGroup.emoji ?? contactGroup.name,
|
contactGroup.emoji ?? contactGroup.name,
|
||||||
style: const TextStyle(fontSize: 18),
|
style: const TextStyle(fontSize: 18),
|
||||||
),
|
),
|
||||||
shape: const StadiumBorder(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<ContactGroup> inFirstSeenOrder(
|
||||||
|
List<ContactGroup> contactGroups,
|
||||||
|
List<int> 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]!];
|
||||||
|
}
|
||||||
|
|
@ -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<int> 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<Set<String>?> onAudienceChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StoryTargetSelector> createState() => _StoryTargetSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StoryTargetSelectorState extends State<StoryTargetSelector> {
|
||||||
|
List<ContactGroup> _contactGroups = [];
|
||||||
|
final List<int> _order = [];
|
||||||
|
late final StreamSubscription<List<ContactGroup>> _subscription;
|
||||||
|
Set<String>? _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<void> _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<void> _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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/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/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.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/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/display_time_picker.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart';
|
||||||
|
|
@ -67,6 +68,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
bool loadingImage = true;
|
bool loadingImage = true;
|
||||||
bool isDisposed = false;
|
bool isDisposed = false;
|
||||||
HashSet<String> selectedGroupIds = HashSet();
|
HashSet<String> selectedGroupIds = HashSet();
|
||||||
|
final StoryTarget storyTarget = StoryTarget();
|
||||||
double pixelRatio = 1;
|
double pixelRatio = 1;
|
||||||
VideoPlayerController? videoController;
|
VideoPlayerController? videoController;
|
||||||
|
|
||||||
|
|
@ -352,6 +354,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
Future<void> _toggleRequiresAuth() async {
|
Future<void> _toggleRequiresAuth() async {
|
||||||
await mediaService.setRequiresAuth(!media.requiresAuthentication);
|
await mediaService.setRequiresAuth(!media.requiresAuthentication);
|
||||||
selectedGroupIds = HashSet();
|
selectedGroupIds = HashSet();
|
||||||
|
storyTarget.clear();
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,6 +364,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
if (widget.sendToGroup == null) {
|
if (widget.sendToGroup == null) {
|
||||||
selectedGroupIds.clear();
|
selectedGroupIds.clear();
|
||||||
}
|
}
|
||||||
|
storyTarget.clear();
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
_displayLimitBeforeWidget = media.displayLimitInMilliseconds;
|
_displayLimitBeforeWidget = media.displayLimitInMilliseconds;
|
||||||
|
|
@ -467,6 +471,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
mediaFileService: mediaService,
|
mediaFileService: mediaService,
|
||||||
additionalData: getAdditionalData(),
|
additionalData: getAdditionalData(),
|
||||||
sendToWidget: _sendToWidget,
|
sendToWidget: _sendToWidget,
|
||||||
|
storyTarget: storyTarget,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter/foundation.dart' show setEquals;
|
import 'package:flutter/foundation.dart' show setEquals;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.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/locator.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.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/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/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/providers/purchases.provider.dart';
|
import 'package:twonly/src/providers/purchases.provider.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.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/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/connection_status.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/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/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/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_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/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/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/components/missing_backup_setup.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.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<ChatListView>
|
||||||
StreamSubscription<Map<String, VerificationStatus>>? _verificationSub;
|
StreamSubscription<Map<String, VerificationStatus>>? _verificationSub;
|
||||||
StreamSubscription<List<(int, ContactGroup)>>? _contactGroupsSub;
|
StreamSubscription<List<(int, ContactGroup)>>? _contactGroupsSub;
|
||||||
StreamSubscription<List<(String, ContactGroup)>>? _chatContactGroupsSub;
|
StreamSubscription<List<(String, ContactGroup)>>? _chatContactGroupsSub;
|
||||||
|
StreamSubscription<List<StoryItem>>? _storiesSub;
|
||||||
|
StreamSubscription<List<OwnStoryItem>>? _ownStoriesSub;
|
||||||
|
final StoryExpiryTimer _ownStoryExpiry = StoryExpiryTimer();
|
||||||
Timer? _typingUpdateTimer;
|
Timer? _typingUpdateTimer;
|
||||||
final Set<String> _precachedMediaIds = {};
|
final Set<String> _precachedMediaIds = {};
|
||||||
List<Group> _groupsNotPinned = [];
|
List<Group> _groupsNotPinned = [];
|
||||||
|
|
@ -61,6 +68,8 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
Map<String, VerificationStatus> _verificationByGroup = {};
|
Map<String, VerificationStatus> _verificationByGroup = {};
|
||||||
Map<int, List<ContactGroup>> _contactGroupsByUser = {};
|
Map<int, List<ContactGroup>> _contactGroupsByUser = {};
|
||||||
Map<String, List<ContactGroup>> _contactGroupsByGroup = {};
|
Map<String, List<ContactGroup>> _contactGroupsByGroup = {};
|
||||||
|
Map<String, List<StoryItem>> _storiesByGroup = {};
|
||||||
|
List<OwnStoryItem> _ownStories = [];
|
||||||
|
|
||||||
final ValueNotifier<bool> _hasContacts = ValueNotifier(false);
|
final ValueNotifier<bool> _hasContacts = ValueNotifier(false);
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
@ -231,6 +240,42 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
}
|
}
|
||||||
setState(() => _contactGroupsByGroup = contactGroups);
|
setState(() => _contactGroupsByGroup = contactGroups);
|
||||||
});
|
});
|
||||||
|
_storiesSub = twonlyDB.storiesDao.watchReceivedStoryItems().listen((items) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final byGroup = <String, List<StoryItem>>{};
|
||||||
|
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<OwnStoryItem> 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
|
/// Labels of a chat: those of the contact for direct chats, those of the
|
||||||
|
|
@ -265,6 +310,9 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
_verificationSub?.cancel();
|
_verificationSub?.cancel();
|
||||||
_contactGroupsSub?.cancel();
|
_contactGroupsSub?.cancel();
|
||||||
_chatContactGroupsSub?.cancel();
|
_chatContactGroupsSub?.cancel();
|
||||||
|
_storiesSub?.cancel();
|
||||||
|
_ownStoriesSub?.cancel();
|
||||||
|
_ownStoryExpiry.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,12 +355,22 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
children: [
|
children: [
|
||||||
ConnectionStatusComp(
|
ConnectionStatusComp(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
|
// The profile is where the user's own story is followed.
|
||||||
onTap: () => context.push(Routes.settingsProfile),
|
onTap: () => context.push(Routes.settingsProfile),
|
||||||
child: AvatarIcon(
|
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,
|
myAvatar: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: context.color.onSurface.withAlpha(20),
|
color: context.color.onSurface.withAlpha(20),
|
||||||
),
|
),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
|
|
@ -446,6 +504,7 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
useSharedSummary: true,
|
useSharedSummary: true,
|
||||||
verificationStatus: _verificationByGroup[group.groupId],
|
verificationStatus: _verificationByGroup[group.groupId],
|
||||||
contactGroups: _contactGroupsFor(group),
|
contactGroups: _contactGroupsFor(group),
|
||||||
|
storyItems: _storiesByGroup[group.groupId] ?? const [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -475,6 +534,7 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
useSharedSummary: true,
|
useSharedSummary: true,
|
||||||
verificationStatus: _verificationByGroup[group.groupId],
|
verificationStatus: _verificationByGroup[group.groupId],
|
||||||
contactGroups: _contactGroupsFor(group),
|
contactGroups: _contactGroupsFor(group),
|
||||||
|
storyItems: _storiesByGroup[group.groupId] ?? const [],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.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/locator.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.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/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/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.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/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_groups.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/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/components/verification_badge.comp.dart';
|
||||||
import 'package:twonly/src/visual/context_menu/group.context_menu.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/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_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/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 {
|
class GroupListItemComp extends StatefulWidget {
|
||||||
const GroupListItemComp({
|
const GroupListItemComp({
|
||||||
|
|
@ -33,6 +37,7 @@ class GroupListItemComp extends StatefulWidget {
|
||||||
this.useSharedSummary = false,
|
this.useSharedSummary = false,
|
||||||
this.verificationStatus,
|
this.verificationStatus,
|
||||||
this.contactGroups = const [],
|
this.contactGroups = const [],
|
||||||
|
this.storyItems,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final Group group;
|
final Group group;
|
||||||
|
|
@ -46,6 +51,9 @@ class GroupListItemComp extends StatefulWidget {
|
||||||
final VerificationStatus? verificationStatus;
|
final VerificationStatus? verificationStatus;
|
||||||
final List<ContactGroup> contactGroups;
|
final List<ContactGroup> contactGroups;
|
||||||
|
|
||||||
|
/// The contact's story items in a direct chat. Null subscribes itself.
|
||||||
|
final List<StoryItem>? storyItems;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GroupListItemComp> createState() => _UserListItem();
|
State<GroupListItemComp> createState() => _UserListItem();
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +71,9 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
StreamSubscription<List<MediaFile>>? _lastMediaFilesStream;
|
StreamSubscription<List<MediaFile>>? _lastMediaFilesStream;
|
||||||
Contact? _directContact;
|
Contact? _directContact;
|
||||||
StreamSubscription<List<Contact>>? _directContactStream;
|
StreamSubscription<List<Contact>>? _directContactStream;
|
||||||
|
List<StoryItem> _storyItems = const [];
|
||||||
|
StreamSubscription<List<StoryItem>>? _storyItemsStream;
|
||||||
|
final StoryExpiryTimer _storyExpiry = StoryExpiryTimer();
|
||||||
|
|
||||||
List<Message> _previewMessages = [];
|
List<Message> _previewMessages = [];
|
||||||
final List<MediaFile> _previewMediaFiles = [];
|
final List<MediaFile> _previewMediaFiles = [];
|
||||||
|
|
@ -75,6 +86,8 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
_applyContacts();
|
_applyContacts();
|
||||||
_lastReaction = widget.lastReaction;
|
_lastReaction = widget.lastReaction;
|
||||||
_lastMessage = widget.lastMessage;
|
_lastMessage = widget.lastMessage;
|
||||||
|
_storyItems = widget.storyItems ?? const [];
|
||||||
|
_scheduleStoryExpiry();
|
||||||
_applyMediaFiles();
|
_applyMediaFiles();
|
||||||
if (widget.useSharedSummary) {
|
if (widget.useSharedSummary) {
|
||||||
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
|
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
|
||||||
|
|
@ -95,6 +108,11 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
|
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
|
||||||
}
|
}
|
||||||
if (widget.mediaFiles != oldWidget.mediaFiles) _applyMediaFiles();
|
if (widget.mediaFiles != oldWidget.mediaFiles) _applyMediaFiles();
|
||||||
|
if (widget.storyItems != null &&
|
||||||
|
widget.storyItems != oldWidget.storyItems) {
|
||||||
|
_storyItems = widget.storyItems!;
|
||||||
|
_scheduleStoryExpiry();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyContacts() {
|
void _applyContacts() {
|
||||||
|
|
@ -120,10 +138,22 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
_lastMessageStream?.cancel();
|
_lastMessageStream?.cancel();
|
||||||
_lastMediaFilesStream?.cancel();
|
_lastMediaFilesStream?.cancel();
|
||||||
_directContactStream?.cancel();
|
_directContactStream?.cancel();
|
||||||
|
_storyItemsStream?.cancel();
|
||||||
|
_storyExpiry.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initStreams() async {
|
Future<void> 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) {
|
if (!widget.useSharedSummary) {
|
||||||
final lastMsgStream = await twonlyDB.messagesDao.watchLastMessage(
|
final lastMsgStream = await twonlyDB.messagesDao.watchLastMessage(
|
||||||
widget.group.groupId,
|
widget.group.groupId,
|
||||||
|
|
@ -319,6 +349,72 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<StoryItem> 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<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return StreamBuilder<void>(
|
return StreamBuilder<void>(
|
||||||
|
|
@ -414,21 +510,8 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
leading: GestureDetector(
|
leading: GestureDetector(
|
||||||
onTap: () async {
|
onTap: _onAvatarTap,
|
||||||
if (widget.group.isDirectChat) {
|
child: _avatar(),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
trailing: (widget.group.leftGroup || _receiverDeletedAccount)
|
trailing: (widget.group.leftGroup || _receiverDeletedAccount)
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter/foundation.dart' show setEquals;
|
import 'package:flutter/foundation.dart' show setEquals;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.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/locator.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.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/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/model/memory_item.model.dart';
|
import 'package:twonly/src/model/memory_item.model.dart';
|
||||||
|
|
@ -563,6 +565,25 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> scrollToMessage(String messageId) async {
|
Future<void> 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(
|
var index = _data.chatItems.indexWhere(
|
||||||
(x) => x.isMessage && x.message!.messageId == messageId,
|
(x) => x.isMessage && x.message!.messageId == messageId,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.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/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
|
@ -80,7 +82,9 @@ class ResponseContainer extends StatelessWidget {
|
||||||
contact: quotedMessage?.senderId == null
|
contact: quotedMessage?.senderId == null
|
||||||
? null
|
? null
|
||||||
: contactsById?[quotedMessage!.senderId],
|
: 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,
|
showBorder: false,
|
||||||
showLeftBorder: false,
|
showLeftBorder: false,
|
||||||
),
|
),
|
||||||
|
|
@ -169,6 +173,17 @@ class _ResponsePreviewState extends State<ResponsePreview> {
|
||||||
if (mounted) setState(() {});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
String? subtitle;
|
String? subtitle;
|
||||||
|
|
@ -219,21 +234,28 @@ class _ResponsePreviewState extends State<ResponsePreview> {
|
||||||
_username = context.lang.you;
|
_username = context.lang.you;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_message!.isStory) {
|
||||||
|
subtitle = _storyAlive ? context.lang.story : context.lang.storyExpired;
|
||||||
|
}
|
||||||
|
|
||||||
color = getMessageColor(_message!.senderId != null);
|
color = getMessageColor(_message!.senderId != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
final hasImage =
|
final hasImage =
|
||||||
_message != null &&
|
_message != null &&
|
||||||
_message!.mediaStored &&
|
(_message!.mediaStored || _storyAlive) &&
|
||||||
_mediaService != null &&
|
_mediaService != null &&
|
||||||
_mediaService!.mediaFile.type != MediaType.audio;
|
_mediaService!.mediaFile.type != MediaType.audio;
|
||||||
|
|
||||||
Widget? imageWidget;
|
Widget? imageWidget;
|
||||||
if (hasImage) {
|
if (hasImage) {
|
||||||
final isVideo = _mediaService!.mediaFile.type == MediaType.video;
|
final isVideo = _mediaService!.mediaFile.type == MediaType.video;
|
||||||
|
// A story nobody saved is still in its temporary file.
|
||||||
final pathToCheck = isVideo
|
final pathToCheck = isVideo
|
||||||
? _mediaService!.thumbnailPath
|
? _mediaService!.thumbnailPath
|
||||||
: _mediaService!.storedPath;
|
: _message!.mediaStored
|
||||||
|
? _mediaService!.storedPath
|
||||||
|
: _mediaService!.tempPath;
|
||||||
imageWidget = Container(
|
imageWidget = Container(
|
||||||
height: 40,
|
height: 40,
|
||||||
width: 40,
|
width: 40,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'package:screen_protector/screen_protector.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.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/mediafiles.table.dart'
|
||||||
show DownloadState, MediaType;
|
show DownloadState, MediaType;
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
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_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_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/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/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:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart';
|
||||||
import 'package:video_player/video_player.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 {
|
class MediaViewerView extends StatefulWidget {
|
||||||
const MediaViewerView(this.group, {super.key, this.initialMessage});
|
const MediaViewerView(Group this.group, {super.key, this.initialMessage})
|
||||||
final Group group;
|
: 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 Message? initialMessage;
|
||||||
|
final StorySource? story;
|
||||||
@override
|
@override
|
||||||
State<MediaViewerView> createState() => _MediaViewerViewState();
|
State<MediaViewerView> createState() => _MediaViewerViewState();
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +70,7 @@ class MediaViewerView extends StatefulWidget {
|
||||||
class _MediaViewerViewState extends State<MediaViewerView> {
|
class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
Timer? nextMediaTimer;
|
Timer? nextMediaTimer;
|
||||||
Timer? progressTimer;
|
Timer? progressTimer;
|
||||||
|
final StoryExpiryTimer _storyExpiryTimer = StoryExpiryTimer();
|
||||||
|
|
||||||
bool showShortReactions = false;
|
bool showShortReactions = false;
|
||||||
double mediaViewerDistanceFromBottom = 0;
|
double mediaViewerDistanceFromBottom = 0;
|
||||||
|
|
@ -79,10 +106,23 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
|
|
||||||
bool _isTransitioning = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_currentMediaSender = widget.group.groupName;
|
_group = widget.group;
|
||||||
|
_currentMediaSender = widget.group?.groupName ?? '';
|
||||||
|
|
||||||
|
if (_isStory) {
|
||||||
|
unawaited(_loadStory());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (widget.initialMessage != null &&
|
if (widget.initialMessage != null &&
|
||||||
!widget.initialMessage!.isWidgetMedia) {
|
!widget.initialMessage!.isWidgetMedia) {
|
||||||
|
|
@ -96,6 +136,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
nextMediaTimer?.cancel();
|
nextMediaTimer?.cancel();
|
||||||
progressTimer?.cancel();
|
progressTimer?.cancel();
|
||||||
|
_storyExpiryTimer.cancel();
|
||||||
_subscription?.cancel();
|
_subscription?.cancel();
|
||||||
downloadStateListener?.cancel();
|
downloadStateListener?.cancel();
|
||||||
progress.dispose();
|
progress.dispose();
|
||||||
|
|
@ -106,14 +147,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
|
|
||||||
// Persist draft message on close
|
// Persist draft message on close
|
||||||
final draftText = textMessageController.text;
|
final draftText = textMessageController.text;
|
||||||
|
if (!_isStory) {
|
||||||
unawaited(
|
unawaited(
|
||||||
twonlyDB.groupsDao.updateGroup(
|
twonlyDB.groupsDao.updateGroup(
|
||||||
widget.group.groupId,
|
widget.group!.groupId,
|
||||||
GroupsCompanion(
|
GroupsCompanion(
|
||||||
draftMessage: Value(draftText.isEmpty ? null : draftText),
|
draftMessage: Value(draftText.isEmpty ? null : draftText),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
textMessageController.dispose();
|
textMessageController.dispose();
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -132,9 +175,54 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
|
|
||||||
final Mutex _messageUpdateLock = Mutex();
|
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<void> _loadStory() async {
|
||||||
|
final story = widget.story!;
|
||||||
|
final now = clock.now();
|
||||||
|
var playlist = <Message>[];
|
||||||
|
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<void> listenForUnopenedMedia(bool firstRun) async {
|
Future<void> listenForUnopenedMedia(bool firstRun) async {
|
||||||
_subscription = twonlyDB.messagesDao
|
_subscription = twonlyDB.messagesDao
|
||||||
.watchMediaNotOpened(widget.group.groupId)
|
.watchMediaNotOpened(widget.group!.groupId)
|
||||||
.listen((messages) async {
|
.listen((messages) async {
|
||||||
await _messageUpdateLock.protect(() async {
|
await _messageUpdateLock.protect(() async {
|
||||||
for (final msg in messages) {
|
for (final msg in messages) {
|
||||||
|
|
@ -193,8 +281,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
_isTransitioning = true;
|
_isTransitioning = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/// Remove the current media file in case it is not set to unlimited
|
/// Remove the current media file in case it is not set to unlimited.
|
||||||
if (currentMedia != null) {
|
/// A story replays until it expires, whatever the file says.
|
||||||
|
if (currentMedia != null && !_isStory) {
|
||||||
if (!imageSaved &&
|
if (!imageSaved &&
|
||||||
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
await currentMedia!.fullMediaRemoval();
|
await currentMedia!.fullMediaRemoval();
|
||||||
|
|
@ -207,9 +296,12 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
|
|
||||||
nextMediaTimer?.cancel();
|
nextMediaTimer?.cancel();
|
||||||
progressTimer?.cancel();
|
progressTimer?.cancel();
|
||||||
|
_storyExpiryTimer.cancel();
|
||||||
|
|
||||||
if (allMediaFiles.isEmpty) {
|
if (allMediaFiles.isEmpty && _isStory) {
|
||||||
final group = await twonlyDB.groupsDao.getGroup(widget.group.groupId);
|
if (mounted) Navigator.pop(context);
|
||||||
|
} else if (allMediaFiles.isEmpty) {
|
||||||
|
final group = await twonlyDB.groupsDao.getGroup(widget.group!.groupId);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
if (group != null &&
|
if (group != null &&
|
||||||
group.draftMessage != null &&
|
group.draftMessage != null &&
|
||||||
|
|
@ -232,6 +324,24 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
|
|
||||||
Future<void> loadAndDownloadCurrentMedia({bool showTwonly = false}) async {
|
Future<void> loadAndDownloadCurrentMedia({bool showTwonly = false}) async {
|
||||||
if (!mounted || !context.mounted) return;
|
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) {
|
if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) {
|
||||||
return advanceToNextMediaOrExit();
|
return advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
|
|
@ -270,7 +380,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
await advanceToNextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
return;
|
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(() {
|
setState(() {
|
||||||
_showDownloadingLoader = true;
|
_showDownloadingLoader = true;
|
||||||
});
|
});
|
||||||
|
|
@ -354,20 +466,25 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
Log.warn(
|
Log.warn(
|
||||||
'Temp media file not found for media ID: ${currentMediaLocal.mediaFile.mediaId}',
|
'Temp media file not found for media ID: ${currentMediaLocal.mediaFile.mediaId}',
|
||||||
);
|
);
|
||||||
|
if (!_isOwnStory) {
|
||||||
await RustApi.requestMediaReupload(
|
await RustApi.requestMediaReupload(
|
||||||
mediaId: currentMediaLocal.mediaFile.mediaId,
|
mediaId: currentMediaLocal.mediaFile.mediaId,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return advanceToNextMediaOrExit();
|
return advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The server can now delete the encrypted bytes, as the user has successfully opened it.
|
// The server can now delete the encrypted bytes, as the user has successfully opened it.
|
||||||
|
final downloadToken = currentMediaLocal.mediaFile.downloadToken;
|
||||||
|
if (downloadToken != null) {
|
||||||
Log.info(
|
Log.info(
|
||||||
'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}',
|
'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}',
|
||||||
);
|
);
|
||||||
unawaitedRustCall(
|
unawaitedRustCall(
|
||||||
RustApi.downloadDone(token: currentMediaLocal.mediaFile.downloadToken!),
|
RustApi.downloadDone(token: downloadToken),
|
||||||
'downloadDone',
|
'downloadDone',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (currentMediaLocal.mediaFile.type == MediaType.video) {
|
if (currentMediaLocal.mediaFile.type == MediaType.video) {
|
||||||
await _setupVideoPlayer(currentMediaLocal);
|
await _setupVideoPlayer(currentMediaLocal);
|
||||||
|
|
@ -383,21 +500,25 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateSenderInfo() async {
|
Future<void> _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(
|
final sender = await twonlyDB.contactsDao.getContactById(
|
||||||
currentMessage!.senderId!,
|
currentMessage!.senderId!,
|
||||||
);
|
);
|
||||||
if (mounted && sender != null) {
|
if (mounted && sender != null) {
|
||||||
_currentMediaSender =
|
_currentMediaSender =
|
||||||
'${getContactDisplayName(sender)} (${widget.group.groupName})';
|
'${getContactDisplayName(sender)} (${group.groupName})';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _notifyMessageOpened(MediaFileService mediaLocal) async {
|
Future<void> _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];
|
var markAsOpenMessageIDs = [currentMessage!.messageId];
|
||||||
|
|
||||||
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
|
if (!_isStory &&
|
||||||
|
userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
|
||||||
mediaLocal.mediaFile.storedFileHash != null) {
|
mediaLocal.mediaFile.storedFileHash != null) {
|
||||||
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
|
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
|
||||||
mediaLocal.mediaFile.storedFileHash!,
|
mediaLocal.mediaFile.storedFileHash!,
|
||||||
|
|
@ -432,7 +553,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.setLooping(
|
await controller.setLooping(
|
||||||
mediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
_isStory || mediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
|
|
@ -458,7 +579,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
progress.value = 1 - ctrl.value.position.inSeconds / duration;
|
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) {
|
if (ctrl.value.position == ctrl.value.duration) {
|
||||||
advanceToNextMediaOrExit();
|
advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
|
|
@ -482,7 +604,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupImageTimer(MediaFileService mediaLocal) {
|
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(
|
canBeSeenUntil = clock.now().add(
|
||||||
Duration(
|
Duration(
|
||||||
milliseconds: mediaLocal.mediaFile.displayLimitInMilliseconds!,
|
milliseconds: mediaLocal.mediaFile.displayLimitInMilliseconds!,
|
||||||
|
|
@ -534,7 +657,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await RustApi.sendEncryptedContentToGroup(
|
await RustApi.sendEncryptedContentToGroup(
|
||||||
groupId: widget.group.groupId,
|
groupId: msg.groupId,
|
||||||
content: pb.EncryptedContent(
|
content: pb.EncryptedContent(
|
||||||
mediaUpdate: pb.EncryptedContent_MediaUpdate(
|
mediaUpdate: pb.EncryptedContent_MediaUpdate(
|
||||||
type: pb.EncryptedContent_MediaUpdate_Type.STORED,
|
type: pb.EncryptedContent_MediaUpdate_Type.STORED,
|
||||||
|
|
@ -561,6 +684,13 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget bottomNavigation() {
|
Widget bottomNavigation() {
|
||||||
|
if (_isOwnStory) {
|
||||||
|
return OwnStoryBottomBar(
|
||||||
|
key: mediaWidgetKey,
|
||||||
|
mediaFile: currentMedia?.mediaFile,
|
||||||
|
onDeleted: advanceToNextMediaOrExit,
|
||||||
|
);
|
||||||
|
}
|
||||||
return MediaViewerBottomNavigationBar(
|
return MediaViewerBottomNavigationBar(
|
||||||
key: mediaWidgetKey,
|
key: mediaWidgetKey,
|
||||||
currentMedia: currentMedia,
|
currentMedia: currentMedia,
|
||||||
|
|
@ -589,15 +719,18 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
progressTimer?.cancel();
|
progressTimer?.cancel();
|
||||||
await videoController?.pause();
|
await videoController?.pause();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
final group = _group;
|
||||||
|
if (group == null) return;
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return CameraSendToView(widget.group);
|
return CameraSendToView(group);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (mounted &&
|
if (mounted &&
|
||||||
|
!_isStory &&
|
||||||
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
await advanceToNextMediaOrExit();
|
await advanceToNextMediaOrExit();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -624,7 +757,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if (textMessageController.text.isNotEmpty) {
|
if (textMessageController.text.isNotEmpty) {
|
||||||
unawaitedRustCall(
|
unawaitedRustCall(
|
||||||
RustApi.insertAndSendText(
|
RustApi.insertAndSendText(
|
||||||
groupId: widget.group.groupId,
|
groupId: currentMessage!.groupId,
|
||||||
text: textMessageController.text,
|
text: textMessageController.text,
|
||||||
quoteMessageId: currentMessage!.messageId,
|
quoteMessageId: currentMessage!.messageId,
|
||||||
),
|
),
|
||||||
|
|
@ -700,7 +833,10 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if (currentMedia != null &&
|
if (currentMedia != null &&
|
||||||
currentMedia?.mediaFile.downloadState != DownloadState.ready)
|
currentMedia?.mediaFile.downloadState != DownloadState.ready)
|
||||||
Positioned.fill(child: _loader()),
|
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(
|
Positioned(
|
||||||
right: 20,
|
right: 20,
|
||||||
top: 27,
|
top: 27,
|
||||||
|
|
@ -755,13 +891,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
),
|
),
|
||||||
if (currentMessage != null)
|
if (currentMessage != null)
|
||||||
AdditionalMessageContent(currentMessage!),
|
AdditionalMessageContent(currentMessage!),
|
||||||
if (currentMedia != null)
|
if (currentMedia != null && !_isOwnStory)
|
||||||
ReactionButtons(
|
ReactionButtons(
|
||||||
show: showShortReactions,
|
show: showShortReactions,
|
||||||
textInputFocused: showSendTextMessageInput,
|
textInputFocused: showSendTextMessageInput,
|
||||||
mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom,
|
mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom,
|
||||||
groupId: widget.group.groupId,
|
groupId: currentMessage!.groupId,
|
||||||
messageId: currentMessage!.messageId,
|
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,
|
emojiKey: emojiKey,
|
||||||
hide: () {
|
hide: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,17 @@ Offset getGlobalOffset(GlobalKey targetKey) {
|
||||||
Future<void> sendReaction(
|
Future<void> sendReaction(
|
||||||
String groupId,
|
String groupId,
|
||||||
String messageId,
|
String messageId,
|
||||||
String emoji,
|
String emoji, {
|
||||||
) async {
|
bool asQuotedText = false,
|
||||||
|
}) async {
|
||||||
|
if (asQuotedText) {
|
||||||
|
await RustApi.insertAndSendText(
|
||||||
|
groupId: groupId,
|
||||||
|
text: emoji,
|
||||||
|
quoteMessageId: messageId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await twonlyDB.reactionsDao.updateMyReaction(
|
await twonlyDB.reactionsDao.updateMyReaction(
|
||||||
messageId,
|
messageId,
|
||||||
emoji,
|
emoji,
|
||||||
|
|
@ -49,6 +58,7 @@ class EmojiReactionWidget extends StatefulWidget {
|
||||||
required this.show,
|
required this.show,
|
||||||
required this.emoji,
|
required this.emoji,
|
||||||
required this.emojiKey,
|
required this.emojiKey,
|
||||||
|
this.asQuotedText = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final String messageId;
|
final String messageId;
|
||||||
|
|
@ -57,6 +67,7 @@ class EmojiReactionWidget extends StatefulWidget {
|
||||||
final bool show;
|
final bool show;
|
||||||
final String emoji;
|
final String emoji;
|
||||||
final GlobalKey<EmojiFloatWidgetState> emojiKey;
|
final GlobalKey<EmojiFloatWidgetState> emojiKey;
|
||||||
|
final bool asQuotedText;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<EmojiReactionWidget> createState() => _EmojiReactionWidgetState();
|
State<EmojiReactionWidget> createState() => _EmojiReactionWidgetState();
|
||||||
|
|
@ -70,7 +81,12 @@ class _EmojiReactionWidgetState extends State<EmojiReactionWidget> {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
key: _targetKey,
|
key: _targetKey,
|
||||||
onTap: () async {
|
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(
|
widget.emojiKey.currentState?.spawn(
|
||||||
getGlobalOffset(_targetKey),
|
getGlobalOffset(_targetKey),
|
||||||
widget.emoji,
|
widget.emoji,
|
||||||
|
|
|
||||||
|
|
@ -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<void> Function() onDeleted;
|
||||||
|
|
||||||
|
Future<void> _showViewers(BuildContext context, MediaFile media) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
builder: (context) => StoryViewersBottomSheet(mediaFile: media),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<List<StoryViewer>>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ class ReactionButtons extends StatefulWidget {
|
||||||
required this.groupId,
|
required this.groupId,
|
||||||
required this.emojiKey,
|
required this.emojiKey,
|
||||||
required this.hide,
|
required this.hide,
|
||||||
|
this.asQuotedText = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -32,6 +33,9 @@ class ReactionButtons extends StatefulWidget {
|
||||||
final String groupId;
|
final String groupId;
|
||||||
final void Function() hide;
|
final void Function() hide;
|
||||||
|
|
||||||
|
/// Send the emoji as a message quoting the target instead of a reaction.
|
||||||
|
final bool asQuotedText;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ReactionButtons> createState() => _ReactionButtonsState();
|
State<ReactionButtons> createState() => _ReactionButtonsState();
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +127,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
show: _renderAnimations,
|
show: _renderAnimations,
|
||||||
emoji: emoji as String,
|
emoji: emoji as String,
|
||||||
emojiKey: widget.emojiKey,
|
emojiKey: widget.emojiKey,
|
||||||
|
asQuotedText: widget.asQuotedText,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|
@ -140,6 +145,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
show: _renderAnimations,
|
show: _renderAnimations,
|
||||||
emoji: emoji,
|
emoji: emoji,
|
||||||
emojiKey: widget.emojiKey,
|
emojiKey: widget.emojiKey,
|
||||||
|
asQuotedText: widget.asQuotedText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
|
@ -160,6 +166,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
widget.groupId,
|
widget.groupId,
|
||||||
widget.messageId,
|
widget.messageId,
|
||||||
layer.text,
|
layer.text,
|
||||||
|
asQuotedText: widget.asQuotedText,
|
||||||
);
|
);
|
||||||
widget.emojiKey.currentState?.spawn(
|
widget.emojiKey.currentState?.spawn(
|
||||||
getGlobalOffset(_keyEmojiPicker),
|
getGlobalOffset(_keyEmojiPicker),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<bool> 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<StoryViewersBottomSheet> createState() =>
|
||||||
|
_StoryViewersBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StoryViewersBottomSheetState extends State<StoryViewersBottomSheet> {
|
||||||
|
StreamSubscription<List<StoryViewer>>? _viewersSub;
|
||||||
|
List<StoryViewer> _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<void> _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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,13 +6,12 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:twonly/locator.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/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/model/memory_item.model.dart';
|
import 'package:twonly/src/model/memory_item.model.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/recipient_status_row.comp.dart';
|
||||||
import 'package:twonly/src/visual/elements/better_list_title.element.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/bottom_sheets/message_history.bottom_sheet.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
|
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
|
||||||
|
|
@ -164,38 +163,11 @@ class _MessageInfoViewState extends State<MessageInfoView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
columns.add(
|
columns.add(
|
||||||
Padding(
|
RecipientStatusRow(
|
||||||
key: ValueKey(groupMember.$1.contactId),
|
key: ValueKey(groupMember.$1.contactId),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
contact: groupMember.$2,
|
||||||
child: Row(
|
status: actionTypeText,
|
||||||
children: [
|
at: actionAt,
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,13 @@ class ContactGroupSettingsView extends StatefulWidget {
|
||||||
const ContactGroupSettingsView({
|
const ContactGroupSettingsView({
|
||||||
this.contactGroup,
|
this.contactGroup,
|
||||||
this.initialShowAsShortcut = false,
|
this.initialShowAsShortcut = false,
|
||||||
|
this.initialShareStories = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ContactGroup? contactGroup;
|
final ContactGroup? contactGroup;
|
||||||
final bool initialShowAsShortcut;
|
final bool initialShowAsShortcut;
|
||||||
|
final bool initialShareStories;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ContactGroupSettingsView> createState() =>
|
State<ContactGroupSettingsView> createState() =>
|
||||||
|
|
@ -69,6 +71,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
||||||
late int _textColor;
|
late int _textColor;
|
||||||
late bool _showAsShortcut;
|
late bool _showAsShortcut;
|
||||||
late bool _showAsLabel;
|
late bool _showAsLabel;
|
||||||
|
late bool _shareStories;
|
||||||
String? _emoji;
|
String? _emoji;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String _memberFilter = '';
|
String _memberFilter = '';
|
||||||
|
|
@ -96,6 +99,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
||||||
_showAsShortcut =
|
_showAsShortcut =
|
||||||
contactGroup?.showAsShortcut ?? widget.initialShowAsShortcut;
|
contactGroup?.showAsShortcut ?? widget.initialShowAsShortcut;
|
||||||
_showAsLabel = contactGroup?.showAsLabel ?? !widget.initialShowAsShortcut;
|
_showAsLabel = contactGroup?.showAsLabel ?? !widget.initialShowAsShortcut;
|
||||||
|
_shareStories = contactGroup?.shareStories ?? widget.initialShareStories;
|
||||||
_emoji = contactGroup?.emoji;
|
_emoji = contactGroup?.emoji;
|
||||||
if (contactGroup != null) unawaited(_loadWidgetUsage(contactGroup.id));
|
if (contactGroup != null) unawaited(_loadWidgetUsage(contactGroup.id));
|
||||||
|
|
||||||
|
|
@ -226,6 +230,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
||||||
backgroundColor: _backgroundColor,
|
backgroundColor: _backgroundColor,
|
||||||
showAsShortcut: _showAsShortcut,
|
showAsShortcut: _showAsShortcut,
|
||||||
showAsLabel: _showAsLabel,
|
showAsLabel: _showAsLabel,
|
||||||
|
shareStories: _shareStories,
|
||||||
);
|
);
|
||||||
if (_isEditing) {
|
if (_isEditing) {
|
||||||
await twonlyDB.contactGroupsDao.updateContactGroup(
|
await twonlyDB.contactGroupsDao.updateContactGroup(
|
||||||
|
|
@ -236,6 +241,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
||||||
backgroundColor: _backgroundColor,
|
backgroundColor: _backgroundColor,
|
||||||
showAsShortcut: _showAsShortcut,
|
showAsShortcut: _showAsShortcut,
|
||||||
showAsLabel: _showAsLabel,
|
showAsLabel: _showAsLabel,
|
||||||
|
shareStories: _shareStories,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await twonlyDB.contactGroupsDao.replaceMembers(
|
await twonlyDB.contactGroupsDao.replaceMembers(
|
||||||
|
|
@ -528,6 +534,13 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
||||||
value: _showAsShortcut,
|
value: _showAsShortcut,
|
||||||
onChanged: (value) => setState(() => _showAsShortcut = value),
|
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)
|
if (_showAsShortcut)
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_groups.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';
|
import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart';
|
||||||
|
|
||||||
class SelectContactGroupsView extends StatefulWidget {
|
class SelectContactGroupsView extends StatefulWidget {
|
||||||
|
|
@ -193,10 +194,17 @@ class _SelectContactGroupsViewState extends State<SelectContactGroupsView> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _contactGroups.isEmpty
|
child: _contactGroups.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: FilledButton.icon(
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.primaryDense,
|
||||||
onPressed: _openSettings,
|
onPressed: _openSettings,
|
||||||
icon: const Icon(Icons.add),
|
child: Row(
|
||||||
label: Text(context.lang.createContactGroup),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.add),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(context.lang.createContactGroup),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
|
|
@ -209,6 +217,8 @@ class _SelectContactGroupsViewState extends State<SelectContactGroupsView> {
|
||||||
context.lang.contactGroupLabelFeature,
|
context.lang.contactGroupLabelFeature,
|
||||||
if (contactGroup.showAsShortcut)
|
if (contactGroup.showAsShortcut)
|
||||||
context.lang.contactGroupShortcutFeature,
|
context.lang.contactGroupShortcutFeature,
|
||||||
|
if (contactGroup.shareStories)
|
||||||
|
context.lang.contactGroupStoriesFeature,
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
double _offsetFromOne = 0;
|
double _offsetFromOne = 0;
|
||||||
bool _isBottomNavVisible = true;
|
bool _isBottomNavVisible = true;
|
||||||
Timer? _disableCameraTimer;
|
Timer? _disableCameraTimer;
|
||||||
|
|
||||||
|
/// Whether [_openEditor] has the editor open over this view.
|
||||||
|
bool _editorOpen = false;
|
||||||
bool _startPreloading = false;
|
bool _startPreloading = false;
|
||||||
|
|
||||||
final MainCameraController _mainCameraController = MainCameraController();
|
final MainCameraController _mainCameraController = MainCameraController();
|
||||||
|
|
@ -153,14 +156,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
file.copySync(newMediaService.originalPath.path);
|
file.copySync(newMediaService.originalPath.path);
|
||||||
if (!mounted) return;
|
await _openEditor(newMediaService);
|
||||||
|
|
||||||
await context.navPush(
|
|
||||||
ShareImageEditorView(
|
|
||||||
mediaFileService: newMediaService,
|
|
||||||
sharedFromGallery: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (HomeViewState.pendingSharedLink != null) {
|
if (HomeViewState.pendingSharedLink != null) {
|
||||||
|
|
@ -269,17 +265,29 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
|
|
||||||
final draftMedia = await twonlyDB.mediaFilesDao.getDraftMediaFile();
|
final draftMedia = await twonlyDB.mediaFilesDao.getDraftMediaFile();
|
||||||
if (draftMedia != null) {
|
if (draftMedia != null) {
|
||||||
|
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<void> _openEditor(MediaFileService mediaFileService) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await Navigator.push(
|
_disableCameraTimer?.cancel();
|
||||||
context,
|
setState(() => _editorOpen = true);
|
||||||
MaterialPageRoute(
|
await _mainCameraController.closeCamera();
|
||||||
builder: (context) => ShareImageEditorView(
|
if (!mounted) return;
|
||||||
mediaFileService: MediaFileService(draftMedia),
|
await context.navPush(
|
||||||
|
ShareImageEditorView(
|
||||||
|
mediaFileService: mediaFileService,
|
||||||
sharedFromGallery: true,
|
sharedFromGallery: true,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
if (mounted) setState(() => _editorOpen = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -453,7 +461,9 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
child: CameraPreviewControllerView(
|
child: CameraPreviewControllerView(
|
||||||
mainController: _mainCameraController,
|
mainController: _mainCameraController,
|
||||||
isVisible:
|
isVisible:
|
||||||
((1 - (_offsetRatio * 4) % 1) == 1) && _activePageIdx == 1,
|
((1 - (_offsetRatio * 4) % 1) == 1) &&
|
||||||
|
_activePageIdx == 1 &&
|
||||||
|
!_editorOpen,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:permission_handler/permission_handler.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/notifications/fcm.notifications.dart';
|
||||||
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||||
|
|
||||||
|
|
@ -30,6 +32,13 @@ class _NotificationViewState extends State<NotificationView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleStoryNotifications() async {
|
||||||
|
await UserService.update((u) {
|
||||||
|
u.storyNotifications = !u.storyNotifications;
|
||||||
|
});
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _resetTokens() async {
|
Future<void> _resetTokens() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingReset = true;
|
_isLoadingReset = true;
|
||||||
|
|
@ -60,6 +69,15 @@ class _NotificationViewState extends State<NotificationView> {
|
||||||
subtitle: Text(context.lang.settingsNotifyPermissionDesc),
|
subtitle: Text(context.lang.settingsNotifyPermissionDesc),
|
||||||
onTap: openAppSettings,
|
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)
|
if (_hasNotificationPermission == true)
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(context.lang.settingsNotifyResetTitle),
|
title: Text(context.lang.settingsNotifyResetTitle),
|
||||||
|
|
|
||||||
148
lib/src/visual/views/settings/profile/own_story_strip.comp.dart
Normal file
148
lib/src/visual/views/settings/profile/own_story_strip.comp.dart
Normal file
|
|
@ -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<OwnStoryStrip> createState() => _OwnStoryStripState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OwnStoryStripState extends State<OwnStoryStrip> {
|
||||||
|
StreamSubscription<List<OwnStoryItem>>? _itemsSub;
|
||||||
|
List<OwnStoryItem> _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<OwnStoryItem> 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<void> _showViewers(OwnStoryItem item) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/better_list_title.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_button.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/groups/group.view.dart';
|
||||||
|
import 'package:twonly/src/visual/views/settings/profile/own_story_strip.comp.dart';
|
||||||
|
|
||||||
class ProfileView extends StatefulWidget {
|
class ProfileView extends StatefulWidget {
|
||||||
const ProfileView({super.key});
|
const ProfileView({super.key});
|
||||||
|
|
@ -125,6 +126,7 @@ class _ProfileViewState extends State<ProfileView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
const OwnStoryStrip(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
BetterListTile(
|
BetterListTile(
|
||||||
leading: const Padding(
|
leading: const Padding(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -34,5 +34,5 @@
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52"
|
"hash": "649d1ed9bcc18ad5ea496f3c10992e31190deaea130850e4c286267d053158e7"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 10
|
"Right": 11
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e"
|
"hash": "c44fdd0c6ac77ef4b604dd0008f69db96edbcac9c3f3dac16d8c0945b5451d9e"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ mod media;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
mod reaction;
|
mod reaction;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
|
pub(crate) mod story;
|
||||||
mod text_message;
|
mod text_message;
|
||||||
mod typing_indicator;
|
mod typing_indicator;
|
||||||
mod user_discovery;
|
mod user_discovery;
|
||||||
|
|
@ -495,7 +496,7 @@ pub async fn handle_decoded_server_message(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut sends_error_response = false;
|
let mut withhold_delivery_receipt = false;
|
||||||
let mut rebuild_session = false;
|
let mut rebuild_session = false;
|
||||||
|
|
||||||
match message_type {
|
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
|
// Never acknowledged: the message was not read, so the sender must
|
||||||
// not be told it was delivered.
|
// not be told it was delivered.
|
||||||
sends_error_response = true;
|
withhold_delivery_receipt = true;
|
||||||
}
|
}
|
||||||
Type::CiphertextV2 => {
|
Type::CiphertextV2 => {
|
||||||
let decrypted = match resumed_plaintext {
|
let decrypted = match resumed_plaintext {
|
||||||
|
|
@ -577,7 +578,7 @@ pub async fn handle_decoded_server_message(
|
||||||
"invalid decrypted client content: {error}"
|
"invalid decrypted client content: {error}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
sends_error_response = process_encrypted_or_queue_error(
|
withhold_delivery_receipt = process_encrypted_or_queue_error(
|
||||||
ctx,
|
ctx,
|
||||||
&mut t,
|
&mut t,
|
||||||
from_user_id,
|
from_user_id,
|
||||||
|
|
@ -585,7 +586,7 @@ pub async fn handle_decoded_server_message(
|
||||||
content,
|
content,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.is_some();
|
== Delivery::Withhold;
|
||||||
}
|
}
|
||||||
// The ratchet has already consumed this message, so it was
|
// The ratchet has already consumed this message, so it was
|
||||||
// handled and the peer is only missing the answer. Falling
|
// 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,
|
error_type as i32,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
sends_error_response = true;
|
withhold_delivery_receipt = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -627,7 +628,7 @@ pub async fn handle_decoded_server_message(
|
||||||
Type::TestNotification => {}
|
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?;
|
queue_sender_delivery_receipt(&mut t, from_user_id, &message.receipt_id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -720,6 +721,16 @@ async fn rebuild_session(ctx: &Arc<Context>, from_user_id: i64) -> Result<()> {
|
||||||
Ok(())
|
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
|
/// Dispatches already-decrypted client-to-client content to its concrete
|
||||||
/// feature module. Transport decoding and Signal decryption do not belong in
|
/// feature module. Transport decoding and Signal decryption do not belong in
|
||||||
/// this dispatcher.
|
/// this dispatcher.
|
||||||
|
|
@ -729,7 +740,15 @@ pub(crate) async fn handle_encrypted(
|
||||||
from_user_id: i64,
|
from_user_id: i64,
|
||||||
receipt_id: &str,
|
receipt_id: &str,
|
||||||
content: proto::EncryptedContent,
|
content: proto::EncryptedContent,
|
||||||
) -> Result<()> {
|
) -> Result<Delivery> {
|
||||||
|
// 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();
|
let notification_content = content.clone();
|
||||||
handle_encrypted_inner(ctx, t, from_user_id, receipt_id, content).await?;
|
handle_encrypted_inner(ctx, t, from_user_id, receipt_id, content).await?;
|
||||||
crate::services::notifications::record_incoming_event(
|
crate::services::notifications::record_incoming_event(
|
||||||
|
|
@ -739,7 +758,8 @@ pub(crate) async fn handle_encrypted(
|
||||||
receipt_id,
|
receipt_id,
|
||||||
¬ification_content,
|
¬ification_content,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
|
Ok(Delivery::Ack)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_encrypted_inner(
|
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;
|
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 {
|
if let Some(error) = content.error_messages {
|
||||||
return errors::handle_error_message(
|
return errors::handle_error_message(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -925,7 +949,7 @@ async fn handle_encrypted_inner(
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(media) = content.media {
|
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 {
|
if let Some(reaction) = content.reaction {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ pub(crate) async fn handle_media(
|
||||||
from_user_id: i64,
|
from_user_id: i64,
|
||||||
group_id: &str,
|
group_id: &str,
|
||||||
media: encrypted_content::Media,
|
media: encrypted_content::Media,
|
||||||
|
is_story: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let media_type = MediaType::try_from(media.r#type)?;
|
let media_type = MediaType::try_from(media.r#type)?;
|
||||||
let widget_only = media.widget_only == Some(true);
|
let widget_only = media.widget_only == Some(true);
|
||||||
|
|
@ -225,10 +226,11 @@ pub(crate) async fn handle_media(
|
||||||
additional_message_data,
|
additional_message_data,
|
||||||
quotes_message_id,
|
quotes_message_id,
|
||||||
is_widget_media,
|
is_widget_media,
|
||||||
|
is_story,
|
||||||
opened_at,
|
opened_at,
|
||||||
opened_by_all,
|
opened_by_all,
|
||||||
created_at
|
created_at
|
||||||
) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
"#,
|
"#,
|
||||||
group_id,
|
group_id,
|
||||||
media.sender_message_id,
|
media.sender_message_id,
|
||||||
|
|
@ -237,6 +239,7 @@ pub(crate) async fn handle_media(
|
||||||
media.additional_message_data,
|
media.additional_message_data,
|
||||||
media.quote_message_id,
|
media.quote_message_id,
|
||||||
widget_only,
|
widget_only,
|
||||||
|
is_story,
|
||||||
opened_at,
|
opened_at,
|
||||||
opened_by_all,
|
opened_by_all,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
|
@ -244,9 +247,12 @@ pub(crate) async fn handle_media(
|
||||||
.execute(&mut **t)
|
.execute(&mut **t)
|
||||||
.await?;
|
.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::increase_last_message_exchange(t, group_id, timestamp).await?;
|
||||||
Group::record_media_exchange(t, group_id, true, 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);
|
spawn_media_download(ctx, media_id);
|
||||||
|
|
@ -300,8 +306,19 @@ pub(crate) async fn handle_media_update(
|
||||||
.execute(&mut **t)
|
.execute(&mut **t)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// 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());
|
spawn_media_store(ctx, media_id.clone());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
MediaUpdateType::DecryptionError => {
|
MediaUpdateType::DecryptionError => {
|
||||||
// The upload state and the requester list are owned by the reupload
|
// The upload state and the requester list are owned by the reupload
|
||||||
// path itself, which appends this contact instead of replacing
|
// path itself, which appends this contact instead of replacing
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::handle_encrypted;
|
use super::{handle_encrypted, Delivery};
|
||||||
use crate::api::proto::client::{self as proto};
|
use crate::api::proto::client::{self as proto};
|
||||||
use crate::api::Server;
|
use crate::api::Server;
|
||||||
use crate::bridge::api::ServerResult;
|
use crate::bridge::api::ServerResult;
|
||||||
|
|
@ -167,16 +167,16 @@ pub(crate) async fn process_encrypted_or_queue_error(
|
||||||
from_user_id: i64,
|
from_user_id: i64,
|
||||||
receipt_id: &str,
|
receipt_id: &str,
|
||||||
content: proto::EncryptedContent,
|
content: proto::EncryptedContent,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Delivery> {
|
||||||
let group_id = content.group_id.clone();
|
let group_id = content.group_id.clone();
|
||||||
|
|
||||||
match handle_encrypted(ctx, t, from_user_id, receipt_id, content).await {
|
match handle_encrypted(ctx, t, from_user_id, receipt_id, content).await {
|
||||||
Ok(()) => Ok(None),
|
Ok(delivery) => Ok(delivery),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let description = error.to_string();
|
let description = error.to_string();
|
||||||
if description.contains("group join arrived before") {
|
if description.contains("group join arrived before") {
|
||||||
queue_retry_control(t, from_user_id, receipt_id).await?;
|
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") {
|
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)
|
.execute(&mut **t)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Some(outgoing_receipt_id))
|
Ok(Delivery::Withhold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
354
rust/src/api/messages/incoming/story.rs
Normal file
354
rust/src/api/messages/incoming/story.rs
Normal file
|
|
@ -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<Context>,
|
||||||
|
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<Context>)> {
|
||||||
|
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<i64>) -> Result<String> {
|
||||||
|
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<Context>,
|
||||||
|
from: i64,
|
||||||
|
receipt_id: &str,
|
||||||
|
content: EncryptedContent,
|
||||||
|
) -> Result<Delivery> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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<Option<i64>> =
|
||||||
|
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<i64> =
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ pub(crate) async fn handle_text_message(
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Group::increase_last_message_exchange(t, group_id, timestamp).await?;
|
Group::increase_last_message_exchange(t, group_id, timestamp).await?;
|
||||||
|
Group::record_text_or_media(t, group_id, timestamp).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +73,17 @@ pub(crate) async fn handle_message_update(
|
||||||
.remove_files_if_deleted(t, &media.media_id, &media.media_type)
|
.remove_files_if_deleted(t, &media.media_id, &media.media_type)
|
||||||
.await?;
|
.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 => {
|
Type::EditText => {
|
||||||
Message::edit_text(
|
Message::edit_text(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ pub(crate) fn content_type_kind(
|
||||||
"MessageUpdate"
|
"MessageUpdate"
|
||||||
} else if content.media.is_some() {
|
} else if content.media.is_some() {
|
||||||
"Media"
|
"Media"
|
||||||
|
} else if content.story.is_some() {
|
||||||
|
"Story"
|
||||||
} else if content.media_update.is_some() {
|
} else if content.media_update.is_some() {
|
||||||
"MediaUpdate"
|
"MediaUpdate"
|
||||||
} else if content.contact_update.is_some() {
|
} else if content.contact_update.is_some() {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ message EncryptedContent {
|
||||||
optional KeyVerificationProof key_verification_proof = 24;
|
optional KeyVerificationProof key_verification_proof = 24;
|
||||||
optional PasswordLessRecovery passwordless_recovery = 26;
|
optional PasswordLessRecovery passwordless_recovery = 26;
|
||||||
optional PasswordLessRecoveryHeartbeat passwordless_recovery_heartbeat = 27;
|
optional PasswordLessRecoveryHeartbeat passwordless_recovery_heartbeat = 27;
|
||||||
|
optional Story story = 30;
|
||||||
|
|
||||||
message ErrorMessages {
|
message ErrorMessages {
|
||||||
enum Type {
|
enum Type {
|
||||||
|
|
@ -134,6 +135,17 @@ message EncryptedContent {
|
||||||
optional bool widget_only = 12;
|
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 {
|
message MediaUpdate {
|
||||||
enum Type {
|
enum Type {
|
||||||
REOPENED = 0;
|
REOPENED = 0;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use crate::services::contacts::ContactService;
|
||||||
use crate::services::media_upload::{MediaSizeReport, MediaUploadService};
|
use crate::services::media_upload::{MediaSizeReport, MediaUploadService};
|
||||||
use crate::services::messages::MessageService;
|
use crate::services::messages::MessageService;
|
||||||
use crate::services::outbox_dispatch::OutboxDispatchService;
|
use crate::services::outbox_dispatch::OutboxDispatchService;
|
||||||
|
use crate::services::stories::StoryAudience;
|
||||||
use crate::user_config::UserConfig;
|
use crate::user_config::UserConfig;
|
||||||
use flutter_rust_bridge::frb;
|
use flutter_rust_bridge::frb;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -223,16 +224,24 @@ impl RustApi {
|
||||||
crate::services::location_metadata::prewarm(ctx)
|
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(
|
pub async fn send_media_to_groups(
|
||||||
media_id: String,
|
media_id: String,
|
||||||
group_ids: Vec<String>,
|
group_ids: Vec<String>,
|
||||||
additional_message_data: Option<Vec<u8>>,
|
additional_message_data: Option<Vec<u8>>,
|
||||||
widget_only: bool,
|
widget_only: bool,
|
||||||
|
story_audience: Option<StoryAudience>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ctx = Context::get_static()?;
|
let ctx = Context::get_static()?;
|
||||||
MediaUploadService::new(ctx)
|
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
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ pub mod api;
|
||||||
pub mod callbacks;
|
pub mod callbacks;
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod stories;
|
||||||
pub mod user_config;
|
pub mod user_config;
|
||||||
pub mod webxdc;
|
pub mod webxdc;
|
||||||
pub mod wrapper;
|
pub mod wrapper;
|
||||||
|
|
|
||||||
46
rust/src/bridge/stories.rs
Normal file
46
rust/src/bridge/stories.rs
Normal file
|
|
@ -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<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<String>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
14
rust/src/database/app/migrations/0025_stories.sql
Normal file
14
rust/src/database/app/migrations/0025_stories.sql
Normal file
|
|
@ -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));
|
||||||
17
rust/src/database/app/migrations/0026_last_text_or_media.sql
Normal file
17
rust/src/database/app/migrations/0026_last_text_or_media.sql
Normal file
|
|
@ -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);
|
||||||
|
|
@ -16,7 +16,7 @@ mod legacy_import;
|
||||||
pub mod tables;
|
pub mod tables;
|
||||||
|
|
||||||
pub const APP_DATABASE_FILE: &str = "app_db.sqlite";
|
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
|
/// User-owned application tables in the current Rust schema. Rust-only outbox
|
||||||
/// tables are deliberately absent because they are reconstructed locally.
|
/// tables are deliberately absent because they are reconstructed locally.
|
||||||
|
|
|
||||||
|
|
@ -440,6 +440,28 @@ impl Group {
|
||||||
Ok(is_member)
|
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(
|
pub async fn increase_last_message_exchange_to_now(
|
||||||
tr: &mut Transaction<'_, Sqlite>,
|
tr: &mut Transaction<'_, Sqlite>,
|
||||||
group_id: &str,
|
group_id: &str,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ pub enum TwonlyError {
|
||||||
#[error("encrypted media is {bytes} bytes but the plan allows {limit}")]
|
#[error("encrypted media is {bytes} bytes but the plan allows {limit}")]
|
||||||
MediaTooLarge { bytes: i64, limit: i64 },
|
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
|
/// 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
|
/// 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.
|
/// the update has to be applied again to the version that won.
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
||||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||||
);
|
);
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
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
|
// 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::SseCodec, _, _, _>(
|
||||||
|
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 = <String>::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(
|
fn wire__crate__bridge__groups__fetch_group_state_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
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::SseCodec, _>(
|
||||||
|
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(
|
fn wire__crate__bridge__webxdc__members_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
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::SseCodec, _, _, _>(
|
||||||
|
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(
|
fn wire__crate__bridge__logging__read_last_log_lines_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
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 = <Vec<String>>::sse_decode(&mut deserializer);
|
let api_group_ids = <Vec<String>>::sse_decode(&mut deserializer);
|
||||||
let api_additional_message_data = <Option<Vec<u8>>>::sse_decode(&mut deserializer);
|
let api_additional_message_data = <Option<Vec<u8>>>::sse_decode(&mut deserializer);
|
||||||
let api_widget_only = <bool>::sse_decode(&mut deserializer);
|
let api_widget_only = <bool>::sse_decode(&mut deserializer);
|
||||||
|
let api_story_audience =
|
||||||
|
<Option<crate::bridge::stories::StoryAudience>>::sse_decode(&mut deserializer);
|
||||||
deserializer.end();
|
deserializer.end();
|
||||||
move |context| async move {
|
move |context| async move {
|
||||||
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
|
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_group_ids,
|
||||||
api_additional_message_data,
|
api_additional_message_data,
|
||||||
api_widget_only,
|
api_widget_only,
|
||||||
|
api_story_audience,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(output_ok)
|
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::SseCodec, _, _, _>(
|
||||||
|
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 =
|
||||||
|
<crate::bridge::stories::StoryAudience>::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(
|
fn wire__crate__bridge__groups__update_chat_deletion_time_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
|
@ -6458,10 +6601,17 @@ fn wire__crate__bridge__logging__write_log_impl(
|
||||||
|
|
||||||
#[allow(clippy::unnecessary_literal_unwrap)]
|
#[allow(clippy::unnecessary_literal_unwrap)]
|
||||||
const _: fn() = || {
|
const _: fn() = || {
|
||||||
|
{
|
||||||
let FlameState = None::<crate::bridge::FlameState>.unwrap();
|
let FlameState = None::<crate::bridge::FlameState>.unwrap();
|
||||||
let _: i64 = FlameState.counter;
|
let _: i64 = FlameState.counter;
|
||||||
let _: bool = FlameState.is_expiring;
|
let _: bool = FlameState.is_expiring;
|
||||||
let _: bool = FlameState.is_best_friend;
|
let _: bool = FlameState.is_best_friend;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let StoryAudience = None::<crate::bridge::stories::StoryAudience>.unwrap();
|
||||||
|
let _: bool = StoryAudience.all;
|
||||||
|
let _: Vec<i64> = StoryAudience.contact_group_ids;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Section: related_funcs
|
// Section: related_funcs
|
||||||
|
|
@ -7308,6 +7458,19 @@ impl SseDecode for Option<crate::user_config::PasswordlessRecoveryConfig> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for Option<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 {
|
||||||
|
if (<bool>::sse_decode(deserializer)) {
|
||||||
|
return Some(<crate::bridge::stories::StoryAudience>::sse_decode(
|
||||||
|
deserializer,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for Option<crate::user_config::TwonlySafeBackup> {
|
impl SseDecode for Option<crate::user_config::TwonlySafeBackup> {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
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 = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_contactGroupIds = <Vec<i64>>::sse_decode(deserializer);
|
||||||
|
return crate::bridge::stories::StoryAudience {
|
||||||
|
all: var_all,
|
||||||
|
contact_group_ids: var_contactGroupIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for crate::user_config::ThemeMode {
|
impl SseDecode for crate::user_config::ThemeMode {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
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 = <bool>::sse_decode(deserializer);
|
let mut var_storeLocationInMemories = <bool>::sse_decode(deserializer);
|
||||||
let mut var_autoStoreAllSendUnlimitedMediaFiles = <bool>::sse_decode(deserializer);
|
let mut var_autoStoreAllSendUnlimitedMediaFiles = <bool>::sse_decode(deserializer);
|
||||||
let mut var_typingIndicators = <bool>::sse_decode(deserializer);
|
let mut var_typingIndicators = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_storyNotifications = <bool>::sse_decode(deserializer);
|
||||||
let mut var_showRestoreFlame = <bool>::sse_decode(deserializer);
|
let mut var_showRestoreFlame = <bool>::sse_decode(deserializer);
|
||||||
let mut var_myBestFriendGroupId = <Option<String>>::sse_decode(deserializer);
|
let mut var_myBestFriendGroupId = <Option<String>>::sse_decode(deserializer);
|
||||||
let mut var_signalLastSignedPreKeyUpdated =
|
let mut var_signalLastSignedPreKeyUpdated =
|
||||||
|
|
@ -7747,6 +7923,7 @@ impl SseDecode for crate::user_config::UserConfig {
|
||||||
store_location_in_memories: var_storeLocationInMemories,
|
store_location_in_memories: var_storeLocationInMemories,
|
||||||
auto_store_all_send_unlimited_media_files: var_autoStoreAllSendUnlimitedMediaFiles,
|
auto_store_all_send_unlimited_media_files: var_autoStoreAllSendUnlimitedMediaFiles,
|
||||||
typing_indicators: var_typingIndicators,
|
typing_indicators: var_typingIndicators,
|
||||||
|
story_notifications: var_storyNotifications,
|
||||||
show_restore_flame: var_showRestoreFlame,
|
show_restore_flame: var_showRestoreFlame,
|
||||||
my_best_friend_group_id: var_myBestFriendGroupId,
|
my_best_friend_group_id: var_myBestFriendGroupId,
|
||||||
signal_last_signed_pre_key_updated: var_signalLastSignedPreKeyUpdated,
|
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),
|
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),
|
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),
|
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),
|
12 => wire__crate__bridge__stories__delete_story_item_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),
|
13 => wire__crate__bridge__groups__fetch_group_state_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),
|
14 => wire__crate__bridge__groups__fetch_group_states_for_unjoined_groups_impl(port, ptr, rust_vec_len, data_len),
|
||||||
15 => wire__crate__bridge__groups__flame_state_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_states_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__wrapper__user_discovery__flutter_user_discovery_change_exclusion_for_contact_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_get_current_version_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_update_verification_state_for_user_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__callbacks__init_flutter_callbacks_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__initialize_twonly_flutter_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_standalone_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__webxdc__instance_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__groups__leave_group_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__logging__load_log_file_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__groups__manage_admin_state_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__webxdc__members_impl(port, ptr, rust_vec_len, data_len),
|
27 => wire__crate__bridge__groups__manage_admin_state_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__members_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__webxdc__one_time_instances_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__prepare_bundle_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__stories__purge_expired_stories_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__logging__read_last_log_lines_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__webxdc__refresh_catalog_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__groups__remove_member_from_group_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__groups__restore_flames_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),
|
37 => wire__crate__bridge__api__rust_api_abandon_media_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),
|
38 => wire__crate__bridge__api__rust_api_add_additional_user_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),
|
39 => wire__crate__bridge__api__rust_api_allocate_sequence_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),
|
41 => wire__crate__bridge__api__rust_api_authentication_headers_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_change_username_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_check_for_deleted_usernames_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_check_for_passwordless_notification_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_clear_contact_request_notifications_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_clear_conversation_notifications_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_close_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_confirm_memories_upload_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_connect_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),
|
51 => wire__crate__bridge__api__rust_api_connection_state_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),
|
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_delete_memory_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),
|
||||||
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_delete_account_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_delete_memory_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_delete_widget_media_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_disable_memories_backup_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_download_done_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_download_media_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_download_pending_media_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_ensure_avatar_png_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_establish_signal_session_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_events_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_finish_started_media_uploads_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_force_ipa_check_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_memories_url_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_memories_usage_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_plan_balance_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_proof_of_work_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_server_key_for_passwordless_recovery_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_get_user_by_id_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_get_user_data_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_get_user_id_from_username_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_hand_outbox_to_os_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_initialize_media_upload_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_initialize_memory_location_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_additional_data_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_insert_and_send_ask_about_user_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_insert_and_send_contact_share_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_insert_and_send_text_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_ipa_purchase_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_load_plan_balance_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_media_size_limit_report_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_media_step_finished_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_notification_badge_count_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_notify_messages_opened_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_perform_passwordless_recovery_heartbeat_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_prerender_media_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_prewarm_memory_location_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_purge_media_temp_folder_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_purge_widget_media_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_refresh_widget_manifest_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_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_register_home_widget_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_register_passwordless_notification_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_register_passwordless_recovery_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_reload_configuration_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_remove_additional_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_remove_media_files_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_report_user_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_binary_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_request_contact_by_username_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_request_media_reupload_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_request_memories_upload_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_retransmit_all_messages_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_retry_pending_media_reuploads_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_reupload_pending_media_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_save_media_to_gallery_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_binary_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_contact_profile_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_encrypted_content_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_encrypted_content_to_group_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_media_to_groups_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_send_queued_message_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_send_text_message_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_send_typing_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_background_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_home_widget_groups_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_login_token_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_media_display_limit_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_set_media_requires_authentication_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_set_media_trim_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_set_network_available_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_store_media_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_submit_recovery_share_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_sync_widget_permissions_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_toggle_media_remove_audio_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_try_request_contact_by_id_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_unregister_home_widget_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__api__rust_api_update_fcm_token_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__api__rust_api_update_signed_pre_key_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__api__rust_api_upload_pqc_pre_keys_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_changes_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_execute_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__app_database__rust_app_database_legacy_import_complete_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__app_database__rust_app_database_migrate_legacy_database_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__app_database__rust_app_database_select_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_archive_create_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_archive_get_backup_download_token_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_archive_restore_backup_archive_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_get_backup_id_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_get_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_get_identity_backup_bytes_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__backup__rust_backup_identity_import_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__backup__rust_backup_identity_restore_identity_backup_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__backup__rust_backup_identity_set_backup_password_keys_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_decrypt_cloud_media_key_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_encrypt_cloud_media_key_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_get_signal_identity_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_get_user_id_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_import_serialized_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_import_signal_identity_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__key_manager__rust_key_manager_remove_local_credentials_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__key_manager__rust_key_manager_serialize_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__key_manager__rust_key_manager_set_user_id_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_decrypt_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_encrypt_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_generate_bundle_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_generate_pqc_prekeys_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_get_contact_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_get_safety_number_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__signal__rust_signal_get_user_public_key_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__signal__rust_signal_process_prekey_bundle_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__wrapper__signal__rust_signal_reset_contact_session_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__wrapper__rust_utils_generate_shares_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__wrapper__rust_utils_recover_secret_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__webxdc__send_update_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__serve_impl(port, ptr, rust_vec_len, data_len),
|
||||||
169 => wire__crate__bridge__webxdc__updates_after_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__user_config__user_config_api_create_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__user_config__user_config_api_import_json_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__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
|
173 => wire__crate__bridge__webxdc__updates_after_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_create_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),
|
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!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8092,17 +8272,18 @@ fn pde_ffi_dispatcher_sync_impl(
|
||||||
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
||||||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||||
match func_id {
|
match func_id {
|
||||||
37 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len),
|
28 => wire__crate__bridge__stories__max_media_recipients_impl(ptr, rust_vec_len, data_len),
|
||||||
39 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len),
|
40 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len),
|
||||||
51 => {
|
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)
|
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,
|
ptr,
|
||||||
rust_vec_len,
|
rust_vec_len,
|
||||||
data_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!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8877,6 +9058,27 @@ impl flutter_rust_bridge::IntoIntoDart<crate::database::app::SqlValue>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
impl flutter_rust_bridge::IntoDart for FrbWrapper<crate::bridge::stories::StoryAudience> {
|
||||||
|
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<crate::bridge::stories::StoryAudience>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<FrbWrapper<crate::bridge::stories::StoryAudience>>
|
||||||
|
for crate::bridge::stories::StoryAudience
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> FrbWrapper<crate::bridge::stories::StoryAudience> {
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
impl flutter_rust_bridge::IntoDart for crate::user_config::ThemeMode {
|
impl flutter_rust_bridge::IntoDart for crate::user_config::ThemeMode {
|
||||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
match self {
|
match self {
|
||||||
|
|
@ -8965,6 +9167,7 @@ impl flutter_rust_bridge::IntoDart for crate::user_config::UserConfig {
|
||||||
.into_into_dart()
|
.into_into_dart()
|
||||||
.into_dart(),
|
.into_dart(),
|
||||||
self.typing_indicators.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.show_restore_flame.into_into_dart().into_dart(),
|
||||||
self.my_best_friend_group_id.into_into_dart().into_dart(),
|
self.my_best_friend_group_id.into_into_dart().into_dart(),
|
||||||
self.signal_last_signed_pre_key_updated
|
self.signal_last_signed_pre_key_updated
|
||||||
|
|
@ -9797,6 +10000,16 @@ impl SseEncode for Option<crate::user_config::PasswordlessRecoveryConfig> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for Option<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) {
|
||||||
|
<bool>::sse_encode(self.is_some(), serializer);
|
||||||
|
if let Some(value) = self {
|
||||||
|
<crate::bridge::stories::StoryAudience>::sse_encode(value, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for Option<crate::user_config::TwonlySafeBackup> {
|
impl SseEncode for Option<crate::user_config::TwonlySafeBackup> {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
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) {
|
||||||
|
<bool>::sse_encode(self.all, serializer);
|
||||||
|
<Vec<i64>>::sse_encode(self.contact_group_ids, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for crate::user_config::ThemeMode {
|
impl SseEncode for crate::user_config::ThemeMode {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
|
@ -10119,6 +10340,7 @@ impl SseEncode for crate::user_config::UserConfig {
|
||||||
<bool>::sse_encode(self.store_location_in_memories, serializer);
|
<bool>::sse_encode(self.store_location_in_memories, serializer);
|
||||||
<bool>::sse_encode(self.auto_store_all_send_unlimited_media_files, serializer);
|
<bool>::sse_encode(self.auto_store_all_send_unlimited_media_files, serializer);
|
||||||
<bool>::sse_encode(self.typing_indicators, serializer);
|
<bool>::sse_encode(self.typing_indicators, serializer);
|
||||||
|
<bool>::sse_encode(self.story_notifications, serializer);
|
||||||
<bool>::sse_encode(self.show_restore_flame, serializer);
|
<bool>::sse_encode(self.show_restore_flame, serializer);
|
||||||
<Option<String>>::sse_encode(self.my_best_friend_group_id, serializer);
|
<Option<String>>::sse_encode(self.my_best_friend_group_id, serializer);
|
||||||
<Option<chrono::DateTime<chrono::Utc>>>::sse_encode(
|
<Option<chrono::DateTime<chrono::Utc>>>::sse_encode(
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,10 @@ pub async fn run(config: InitConfig, job: Job) -> Result<RunOutcome> {
|
||||||
MediaUploadService::new(&ctx).start_upload(media_id).await?;
|
MediaUploadService::new(&ctx).start_upload(media_id).await?;
|
||||||
}
|
}
|
||||||
Job::Flush => {
|
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 {
|
if let Err(error) = MediaUploadService::new(&ctx).finish_started_uploads().await {
|
||||||
tracing::warn!(%error, "could not finish started media uploads");
|
tracing::warn!(%error, "could not finish started media uploads");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::context::Context;
|
||||||
use crate::database::app::{tables::MediaFile, AppDatabase};
|
use crate::database::app::{tables::MediaFile, AppDatabase};
|
||||||
use crate::error::{Result, TwonlyError};
|
use crate::error::{Result, TwonlyError};
|
||||||
use crate::native::transfer;
|
use crate::native::transfer;
|
||||||
|
use crate::services::stories::{STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS, STORY_LIFETIME_SECONDS};
|
||||||
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
|
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
|
||||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||||
use prost::Message;
|
use prost::Message;
|
||||||
|
|
@ -39,6 +40,8 @@ const REFILL_THRESHOLD_KEY: &str = "direct_media_refill_threshold";
|
||||||
/// durable jobs.
|
/// durable jobs.
|
||||||
const API_NAMESPACE_KEY: &str = "direct_media_api_namespace";
|
const API_NAMESPACE_KEY: &str = "direct_media_api_namespace";
|
||||||
const WATCH_FIRST_DELAY: Duration = Duration::from_secs(2);
|
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);
|
const WATCH_MAX_DELAY: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
static WATCHING: AtomicBool = AtomicBool::new(false);
|
static WATCHING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
@ -159,6 +162,7 @@ struct MessageRow {
|
||||||
quotes_message_id: Option<String>,
|
quotes_message_id: Option<String>,
|
||||||
additional_message_data: Option<Vec<u8>>,
|
additional_message_data: Option<Vec<u8>>,
|
||||||
is_widget_media: i64,
|
is_widget_media: i64,
|
||||||
|
is_story: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(FromRow)]
|
#[derive(FromRow)]
|
||||||
|
|
@ -953,14 +957,22 @@ impl DirectMediaUploadService {
|
||||||
.execute(&database.pool)
|
.execute(&database.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
let messages = sqlx::query_as::<_, MessageRow>(
|
let messages = sqlx::query_as::<_, MessageRow>(
|
||||||
r#"SELECT group_id, message_id, created_at, quotes_message_id,
|
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 = ?"#,
|
FROM messages WHERE media_id = ?"#,
|
||||||
)
|
)
|
||||||
.bind(media_id)
|
.bind(media_id)
|
||||||
.fetch_all(&database.pool)
|
.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::<Vec<_>>();
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return Err(TwonlyError::Generic(
|
return Err(TwonlyError::Generic(
|
||||||
"media has no recipient messages".into(),
|
"media has no recipient messages".into(),
|
||||||
|
|
@ -988,7 +1000,38 @@ impl DirectMediaUploadService {
|
||||||
for recipient in recipients {
|
for recipient in recipients {
|
||||||
let mut download_token = vec![0_u8; 32];
|
let mut download_token = vec![0_u8; 32];
|
||||||
rand::rng().fill_bytes(&mut download_token);
|
rand::rng().fill_bytes(&mut download_token);
|
||||||
let content = EncryptedContent {
|
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()),
|
group_id: Some(message.group_id.clone()),
|
||||||
media: Some(encrypted_content::Media {
|
media: Some(encrypted_content::Media {
|
||||||
sender_message_id: message.message_id.clone(),
|
sender_message_id: message.message_id.clone(),
|
||||||
|
|
@ -1005,6 +1048,7 @@ impl DirectMediaUploadService {
|
||||||
widget_only: Some(message.is_widget_media != 0),
|
widget_only: Some(message.is_widget_media != 0),
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let encrypted_body = send_c2c_message_to_contact()
|
let encrypted_body = send_c2c_message_to_contact()
|
||||||
.ctx(&self.ctx)
|
.ctx(&self.ctx)
|
||||||
|
|
@ -1023,13 +1067,14 @@ impl DirectMediaUploadService {
|
||||||
encrypted_body,
|
encrypted_body,
|
||||||
// Widget media arrives already opened and raises no
|
// Widget media arrives already opened and raises no
|
||||||
// notification, so a push for it only buys the recipient
|
// notification, so a push for it only buys the recipient
|
||||||
// an alert with nothing behind it.
|
// an alert with nothing behind it. A story wakes only for
|
||||||
push_data: (message.is_widget_media == 0).then(|| vec![1]),
|
// the first item in a day, see `story_wakes_recipient`.
|
||||||
|
push_data: wake.then(|| vec![1]),
|
||||||
download_token,
|
download_token,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if dispatches.is_empty() || dispatches.len() > 300 {
|
if dispatches.is_empty() || dispatches.len() > MAX_ATTACHMENT_DISPATCHES {
|
||||||
return Err(TwonlyError::Generic(format!(
|
return Err(TwonlyError::Generic(format!(
|
||||||
"direct-media fan-out has {} dispatches",
|
"direct-media fan-out has {} dispatches",
|
||||||
dispatches.len()
|
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<bool> {
|
||||||
|
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 {
|
fn media_extension(media_type: &str) -> &'static str {
|
||||||
match media_type {
|
match media_type {
|
||||||
"video" => "mp4",
|
"video" => "mp4",
|
||||||
|
|
@ -1090,6 +1193,141 @@ fn write_multipart_body(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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<Arc<Context>> {
|
||||||
|
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::<u32>(&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]
|
#[test]
|
||||||
fn cancelled_watcher_releases_process_claim() {
|
fn cancelled_watcher_releases_process_claim() {
|
||||||
|
|
@ -1399,4 +1637,102 @@ mod tests {
|
||||||
// out makes the server abandon the transfer as `not_in_client_cache`.
|
// out makes the server abandon the transfer as `not_in_client_cache`.
|
||||||
assert_eq!(known, vec!["slot-cached", "slot-reserved"]);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,12 @@ use crate::error::{Result, TwonlyError};
|
||||||
use crate::native::gallery;
|
use crate::native::gallery;
|
||||||
use crate::native::prepare;
|
use crate::native::prepare;
|
||||||
use crate::native::video;
|
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_codec;
|
||||||
use crate::services::media_exif;
|
use crate::services::media_exif;
|
||||||
use crate::services::mediafiles::MediaFileService;
|
use crate::services::mediafiles::MediaFileService;
|
||||||
use crate::services::messages::MessageService;
|
use crate::services::messages::MessageService;
|
||||||
|
use crate::services::stories::{self, StoryAudience};
|
||||||
use crate::user_config::UserConfig;
|
use crate::user_config::UserConfig;
|
||||||
use crate::utils::new_uuid_v7;
|
use crate::utils::new_uuid_v7;
|
||||||
use prost::Message as _;
|
use prost::Message as _;
|
||||||
|
|
@ -161,19 +162,68 @@ impl MediaUploadService {
|
||||||
Ok(media_id)
|
Ok(media_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns an edited media file into one outgoing message per selected group
|
/// Turns an edited media file into one outgoing message per selected group,
|
||||||
/// and hands the send to the background uploader.
|
/// 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(
|
pub async fn insert_into_messages(
|
||||||
&self,
|
&self,
|
||||||
media_id: String,
|
media_id: String,
|
||||||
group_ids: Vec<String>,
|
group_ids: Vec<String>,
|
||||||
additional_message_data: Option<Vec<u8>>,
|
additional_message_data: Option<Vec<u8>>,
|
||||||
widget_only: bool,
|
widget_only: bool,
|
||||||
|
story: Option<StoryAudience>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let database = self.ctx.app_db.read().await.clone();
|
let database = self.ctx.app_db.read().await.clone();
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
let mut transaction = database.pool.begin().await?;
|
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 {
|
if widget_only {
|
||||||
let media = sqlx::query_as::<_, (String, Option<i64>)>(
|
let media = sqlx::query_as::<_, (String, Option<i64>)>(
|
||||||
"SELECT type, display_limit_in_milliseconds FROM media_files WHERE media_id = ?",
|
"SELECT type, display_limit_in_milliseconds FROM media_files WHERE media_id = ?",
|
||||||
|
|
@ -253,8 +303,27 @@ impl MediaUploadService {
|
||||||
.execute(&mut *transaction)
|
.execute(&mut *transaction)
|
||||||
.await?;
|
.await?;
|
||||||
Group::record_media_exchange(&mut transaction, group_id, false, now).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?;
|
transaction.commit().await?;
|
||||||
drop(database);
|
drop(database);
|
||||||
|
|
||||||
|
|
@ -399,6 +468,9 @@ impl MediaUploadService {
|
||||||
if let Err(error) = self.record_plaintext_size(media_id, &temp_path).await {
|
if let Err(error) = self.record_plaintext_size(media_id, &temp_path).await {
|
||||||
tracing::warn!(media_id, %error, "could not record the media size");
|
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
|
// Auto-storing has to happen before the plaintext is consumed, and only
|
||||||
// for media the recipient could have kept anyway.
|
// 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.
|
// Nothing can be delivered to a contact who has not accepted yet.
|
||||||
if receipt.mark_for_retry_after_accepted.is_some() {
|
if receipt.mark_for_retry_after_accepted.is_some() {
|
||||||
let accepted = sqlx::query_scalar::<_, Option<i64>>(
|
let accepted = sqlx::query_scalar::<_, Option<i64>>(
|
||||||
|
|
@ -972,11 +1058,15 @@ impl MediaUploadService {
|
||||||
-- still reopen their own copy.
|
-- still reopen their own copy.
|
||||||
AND (m.sender_id IS NULL OR g.is_direct_chat = 0)
|
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(media_id)
|
||||||
.bind(now - 3 * 60)
|
.bind(now - 3 * 60)
|
||||||
.bind(now - 2 * 24 * 60 * 60)
|
.bind(now - 2 * 24 * 60 * 60)
|
||||||
|
.bind(now - stories::STORY_LIFETIME_SECONDS)
|
||||||
.fetch_one(&database.pool)
|
.fetch_one(&database.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(keepers == 0)
|
Ok(keepers == 0)
|
||||||
|
|
@ -1281,28 +1371,48 @@ impl MediaUploadService {
|
||||||
async fn create_thumbnail(&self, media: &MediaRow) -> Result<()> {
|
async fn create_thumbnail(&self, media: &MediaRow) -> Result<()> {
|
||||||
let files = MediaFileService::new(&self.ctx);
|
let files = MediaFileService::new(&self.ctx);
|
||||||
let stored = files.stored_path(&media.media_id, &media.media_type);
|
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() {
|
if media.media_type == "audio" || !stored.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let media_id = media.media_id.clone();
|
render_thumbnail(
|
||||||
let is_video = media.media_type == "video";
|
&media.media_id,
|
||||||
if let Err(error) = blocking(move || {
|
stored,
|
||||||
if is_video {
|
files.thumbnail_path(&media.media_id),
|
||||||
// Only the frame grab needs the platform's video decoder; the
|
media.media_type == "video",
|
||||||
// scaling and encoding is the same code stills use.
|
)
|
||||||
let frame = thumbnail.with_extension("frame.png");
|
.await;
|
||||||
video::extract_frame(&stored, &frame)?;
|
Ok(())
|
||||||
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
|
/// 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
|
||||||
tracing::warn!(media_id, %error, "could not create a thumbnail");
|
/// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -1501,6 +1611,34 @@ where
|
||||||
.map_err(|error| TwonlyError::Generic(error.to_string()))?
|
.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) {
|
fn remove_file(path: &Path) {
|
||||||
match std::fs::remove_file(path) {
|
match std::fs::remove_file(path) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
|
|
@ -1531,6 +1669,42 @@ fn parse_reupload_requested_by(value: Option<&str>) -> Vec<i64> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn parses_both_reupload_requested_by_encodings() {
|
fn parses_both_reupload_requested_by_encodings() {
|
||||||
assert!(parse_reupload_requested_by(None).is_empty());
|
assert!(parse_reupload_requested_by(None).is_empty());
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::api::runtime::API_EVENTS;
|
||||||
use crate::bridge::api::{ApiEvent, ApiEventKind, RustApi};
|
use crate::bridge::api::{ApiEvent, ApiEventKind, RustApi};
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::error::{Result, TwonlyError};
|
use crate::error::{Result, TwonlyError};
|
||||||
|
use crate::services::media_upload::MediaUploadService;
|
||||||
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
|
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
|
||||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce, Tag};
|
use chacha20poly1305::{ChaCha20Poly1305, Nonce, Tag};
|
||||||
use prost::Message as _;
|
use prost::Message as _;
|
||||||
|
|
@ -238,6 +239,12 @@ impl MediaFileService {
|
||||||
return Ok(());
|
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>(
|
let is_widget = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT is_widget_media FROM media_files WHERE media_id = ?",
|
"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
|
// 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
|
// 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
|
// 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!(
|
let targets = sqlx::query_as!(
|
||||||
ReuploadTarget,
|
ReuploadTarget,
|
||||||
r#"SELECT message_id, sender_id AS "sender_id!: i64" FROM messages
|
r#"SELECT message_id, sender_id AS "sender_id!: i64" FROM messages
|
||||||
WHERE media_id = ? AND sender_id IS NOT NULL
|
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,
|
media_id,
|
||||||
)
|
)
|
||||||
.fetch_all(&database.pool)
|
.fetch_all(&database.pool)
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,7 @@ impl MessageService {
|
||||||
)
|
)
|
||||||
.execute(&mut *t)
|
.execute(&mut *t)
|
||||||
.await?;
|
.await?;
|
||||||
|
Group::record_text_or_media(&mut t, &group_id, timestamp / 1000).await?;
|
||||||
// The commit hook installed on the connection publishes `groups` and
|
// The commit hook installed on the connection publishes `groups` and
|
||||||
// `messages` on its own, so the chat list is already on its way.
|
// `messages` on its own, so the chat list is already on its way.
|
||||||
t.commit().await?;
|
t.commit().await?;
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,5 @@ pub mod mediafiles;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod outbox_dispatch;
|
pub mod outbox_dispatch;
|
||||||
|
pub mod stories;
|
||||||
pub mod webxdc;
|
pub mod webxdc;
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,63 @@ async fn message_sender(
|
||||||
.await?)
|
.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<bool> {
|
||||||
|
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.
|
/// 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
|
/// 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)
|
.is_ok_and(|kind| kind != encrypted_content::media::Type::Reupload)
|
||||||
})
|
})
|
||||||
|| reaction_reaches_author
|
|| 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
|
|| content
|
||||||
.media_update
|
.media_update
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -250,18 +310,28 @@ pub(crate) async fn record_incoming_event(
|
||||||
let conversation_id = content.group_id.clone();
|
let conversation_id = content.group_id.clone();
|
||||||
let now = current_time().timestamp();
|
let now = current_time().timestamp();
|
||||||
let draft = if let Some(message) = content.text_message.as_ref() {
|
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 {
|
Some(NotificationDraft {
|
||||||
event_id: receipt_id.to_owned(),
|
event_id: receipt_id.to_owned(),
|
||||||
notification_id: message.sender_message_id.clone(),
|
notification_id: message.sender_message_id.clone(),
|
||||||
conversation_id,
|
conversation_id,
|
||||||
sender_id: from_user_id,
|
sender_id: from_user_id,
|
||||||
message_id: Some(message.sender_message_id.clone()),
|
message_id: Some(message.sender_message_id.clone()),
|
||||||
kind: if message.quote_message_id.is_some() {
|
kind,
|
||||||
"response"
|
content: reaction,
|
||||||
} else {
|
|
||||||
"text"
|
|
||||||
},
|
|
||||||
content: None,
|
|
||||||
created_at: milliseconds_to_seconds(message.timestamp),
|
created_at: milliseconds_to_seconds(message.timestamp),
|
||||||
})
|
})
|
||||||
} else if let Some(media) = content.media.as_ref() {
|
} 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),
|
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() {
|
} else if let Some(message) = content.additional_data_message.as_ref() {
|
||||||
// A hidden message is state a feature exchanges and leaves no row of
|
// 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
|
// 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
|
// 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
|
// nothing to be told, and a reaction the sender put on their own
|
||||||
// message concerns nobody here at all.
|
// message concerns nobody here at all.
|
||||||
|
let kind = if is_own_story(transaction, &reaction.target_message_id).await? {
|
||||||
|
"story_reaction"
|
||||||
|
} else {
|
||||||
|
"reaction"
|
||||||
|
};
|
||||||
matches!(
|
matches!(
|
||||||
message_sender(transaction, &reaction.target_message_id).await?,
|
message_sender(transaction, &reaction.target_message_id).await?,
|
||||||
Some(None)
|
Some(None)
|
||||||
|
|
@ -341,13 +445,15 @@ pub(crate) async fn record_incoming_event(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
sender_id: from_user_id,
|
sender_id: from_user_id,
|
||||||
message_id: Some(reaction.target_message_id.clone()),
|
message_id: Some(reaction.target_message_id.clone()),
|
||||||
kind: "reaction",
|
kind,
|
||||||
content: Some(reaction.emoji.clone()),
|
content: Some(reaction.emoji.clone()),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
})
|
})
|
||||||
} else if let Some(update) = content.media_update.as_ref() {
|
} else if let Some(update) = content.media_update.as_ref() {
|
||||||
let update_type = encrypted_content::media_update::Type::try_from(update.r#type)?;
|
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 {
|
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::Stored => Some("stored_media"),
|
||||||
encrypted_content::media_update::Type::Reopened => Some("reopened_media"),
|
encrypted_content::media_update::Type::Reopened => Some("reopened_media"),
|
||||||
encrypted_content::media_update::Type::DecryptionError => None,
|
encrypted_content::media_update::Type::DecryptionError => None,
|
||||||
|
|
@ -410,7 +516,7 @@ async fn clear_stale_opened(database: &Arc<AppDatabase>) -> Result<()> {
|
||||||
UPDATE notification_outbox
|
UPDATE notification_outbox
|
||||||
SET cleared_at = ?
|
SET cleared_at = ?
|
||||||
WHERE cleared_at IS NULL
|
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 (
|
AND EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM messages
|
FROM messages
|
||||||
|
|
@ -616,6 +722,12 @@ pub async fn finalize_wakeup(deadline_ms: u64) -> Result<()> {
|
||||||
Ok(Ok(())) => {}
|
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() {
|
if owns_connection && ctx.is_notification_runtime() {
|
||||||
// Bounded on its own rather than out of what is left: closing costs a
|
// 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
|
// 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 = ?
|
SET cleared_at = ?
|
||||||
WHERE message_id = ?
|
WHERE message_id = ?
|
||||||
AND cleared_at IS NULL
|
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)
|
.bind(cleared_at)
|
||||||
|
|
@ -821,6 +933,10 @@ fn localized_body(locale: &str, row: &PendingRow) -> String {
|
||||||
"contact_request" => "notificationContactRequest",
|
"contact_request" => "notificationContactRequest",
|
||||||
"accept_request" => "notificationAcceptRequest",
|
"accept_request" => "notificationAcceptRequest",
|
||||||
"stored_media" => "notificationStoredMediaFile",
|
"stored_media" => "notificationStoredMediaFile",
|
||||||
|
"story" => "notificationStory",
|
||||||
|
"story_reply" => "notificationStoryReply",
|
||||||
|
"story_reaction" => "notificationStoryReaction",
|
||||||
|
"stored_story" => "notificationStoredStory",
|
||||||
"reopened_media" => "notificationReopenedMedia",
|
"reopened_media" => "notificationReopenedMedia",
|
||||||
"reaction" => match (
|
"reaction" => match (
|
||||||
row.target_message_type.as_deref(),
|
row.target_message_type.as_deref(),
|
||||||
|
|
@ -979,6 +1095,108 @@ mod tests {
|
||||||
assert_eq!(localized_body("fr-FR", &row), "wants to connect with you.");
|
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<String>)> =
|
||||||
|
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]
|
#[test]
|
||||||
fn localizes_connection_fallback_notification() {
|
fn localizes_connection_fallback_notification() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
477
rust/src/services/stories.rs
Normal file
477
rust/src/services/stories.rs
Normal file
|
|
@ -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<bool> {
|
||||||
|
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<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<i64>> {
|
||||||
|
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::<BTreeSet<i64>>();
|
||||||
|
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<Context>, audience: &StoryAudience) -> Result<Vec<String>> {
|
||||||
|
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<String> {
|
||||||
|
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<BTreeSet<String>> {
|
||||||
|
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<Context>,
|
||||||
|
t: &mut Transaction<'_, Sqlite>,
|
||||||
|
media_ids: BTreeSet<String>,
|
||||||
|
) -> 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<Context>) -> 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<Context>, 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::<Vec<_>>();
|
||||||
|
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<Context>)> {
|
||||||
|
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::<i64>::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::<Vec<_>>();
|
||||||
|
execute(&ctx, &statements).await?;
|
||||||
|
|
||||||
|
purge_expired(&ctx).await?;
|
||||||
|
|
||||||
|
let database = ctx.app_db.read().await.clone();
|
||||||
|
let rows: Vec<(String, Option<String>)> =
|
||||||
|
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<String> =
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue