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>
|
||||
<key>com.apple.developer.usernotifications.communication</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.usernotifications.filtering</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.eu.twonly.runtime</string>
|
||||
|
|
|
|||
|
|
@ -107,12 +107,16 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
guard let batch = response.batch, !batch.additions.isEmpty else {
|
||||
// Nothing is waiting on screen for this wake-up — widget-only media is
|
||||
// the ordinary case — so the deferred work runs while the extension is
|
||||
// still guaranteed its time, rather than after the content handler has
|
||||
// made it eligible for termination.
|
||||
// Nothing is waiting on screen for this wake-up — widget-only media, a
|
||||
// story the user muted or one that already expired — so the deferred
|
||||
// work runs while the extension is still guaranteed its time, rather
|
||||
// than after the content handler has made it eligible for termination.
|
||||
Self.finalizeRuntime()
|
||||
self.deliverFallback(reason: "notification worker returned no messages")
|
||||
// The worker ran and decided there is nothing to announce. With the
|
||||
// notification filtering entitlement, empty content drops the push
|
||||
// instead of showing its "You got a new message" placeholder.
|
||||
NSLog("Twonly wake-up had nothing to announce; suppressing the alert")
|
||||
self.finish(with: UNNotificationContent())
|
||||
return
|
||||
}
|
||||
self.render(batch: batch, original: request.content)
|
||||
|
|
|
|||
|
|
@ -70,8 +70,6 @@ PODS:
|
|||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
- rust_lib_twonly (0.0.1)
|
||||
- screen_protector (1.5.1):
|
||||
|
|
@ -89,7 +87,6 @@ DEPENDENCIES:
|
|||
- google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`)
|
||||
- google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`)
|
||||
- google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`)
|
||||
- screen_protector (from `.symlinks/plugins/screen_protector/ios`)
|
||||
- SwiftProtobuf
|
||||
|
|
@ -128,8 +125,6 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/google_mlkit_commons/ios"
|
||||
google_mlkit_face_detection:
|
||||
:path: ".symlinks/plugins/google_mlkit_face_detection/ios"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
rust_lib_twonly:
|
||||
:path: ".symlinks/plugins/rust_lib_twonly/ios"
|
||||
screen_protector:
|
||||
|
|
@ -155,7 +150,6 @@ SPEC CHECKSUMS:
|
|||
MLKitFaceDetection: 32549f1e70e6e7731261bf9cea2b74095e2531cb
|
||||
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
rust_lib_twonly: 6586fdf02e31cd8a3ad9f1ee84796da6bbf289bb
|
||||
screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
|
||||
"version" : "11.3.1"
|
||||
"revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a",
|
||||
"version" : "11.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||
import 'package:flutter_sharing_intent/model/sharing_file.dart' show SharedFile;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:twonly/core/bridge/api.dart' as rust_api;
|
||||
import 'package:twonly/core/bridge/stories.dart' as stories;
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
||||
|
|
@ -69,6 +70,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||
// was suspended, and its Rust change broadcast never reached this
|
||||
// process, so the badge has to be re-read on the way back in.
|
||||
unawaited(NativeNotificationService.refreshBadgeCount());
|
||||
// A story may have expired while the app was away.
|
||||
unawaitedRustCall(stories.purgeExpiredStories(), 'purgeExpiredStories');
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
AppState.isAppInBackground = true;
|
||||
unawaited(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../api/server/prekeys.dart';
|
|||
import '../frb_generated.dart';
|
||||
import '../services/media_upload.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
import 'stories.dart';
|
||||
|
||||
// These functions are ignored because they are not marked as `pub`: `api_result`, `empty_api_response`, `from_rust_state`
|
||||
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `ApiConfig`, `ServerResult`
|
||||
|
|
@ -739,17 +740,20 @@ class RustApi {
|
|||
onlySendIfNoReceiptsAreOpen: onlySendIfNoReceiptsAreOpen,
|
||||
);
|
||||
|
||||
/// Creates one outgoing message per selected group and starts the upload.
|
||||
/// Creates one outgoing message per selected group, plus the story rows
|
||||
/// for `story_audience`, and starts the upload they all share.
|
||||
static Future<void> sendMediaToGroups({
|
||||
required String mediaId,
|
||||
required List<String> groupIds,
|
||||
Uint8List? additionalMessageData,
|
||||
required bool widgetOnly,
|
||||
StoryAudience? storyAudience,
|
||||
}) => RustLib.instance.api.crateBridgeApiRustApiSendMediaToGroups(
|
||||
mediaId: mediaId,
|
||||
groupIds: groupIds,
|
||||
additionalMessageData: additionalMessageData,
|
||||
widgetOnly: widgetOnly,
|
||||
storyAudience: storyAudience,
|
||||
);
|
||||
|
||||
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/groups.dart';
|
||||
import 'bridge/logging.dart';
|
||||
import 'bridge/stories.dart';
|
||||
import 'bridge/user_config.dart';
|
||||
import 'bridge/webxdc.dart';
|
||||
import 'bridge/wrapper.dart';
|
||||
|
|
@ -120,6 +121,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
PasswordlessRecoveryConfig
|
||||
dco_decode_box_autoadd_passwordless_recovery_config(dynamic raw);
|
||||
|
||||
@protected
|
||||
StoryAudience dco_decode_box_autoadd_story_audience(dynamic raw);
|
||||
|
||||
@protected
|
||||
TwonlySafeBackup dco_decode_box_autoadd_twonly_safe_backup(dynamic raw);
|
||||
|
||||
|
|
@ -298,6 +302,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
PasswordlessRecoveryConfig?
|
||||
dco_decode_opt_box_autoadd_passwordless_recovery_config(dynamic raw);
|
||||
|
||||
@protected
|
||||
StoryAudience? dco_decode_opt_box_autoadd_story_audience(dynamic raw);
|
||||
|
||||
@protected
|
||||
TwonlySafeBackup? dco_decode_opt_box_autoadd_twonly_safe_backup(dynamic raw);
|
||||
|
||||
|
|
@ -376,6 +383,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
SqlValue dco_decode_sql_value(dynamic raw);
|
||||
|
||||
@protected
|
||||
StoryAudience dco_decode_story_audience(dynamic raw);
|
||||
|
||||
@protected
|
||||
ThemeMode dco_decode_theme_mode(dynamic raw);
|
||||
|
||||
|
|
@ -521,6 +531,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
StoryAudience sse_decode_box_autoadd_story_audience(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
TwonlySafeBackup sse_decode_box_autoadd_twonly_safe_backup(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -733,6 +748,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
StoryAudience? sse_decode_opt_box_autoadd_story_audience(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
TwonlySafeBackup? sse_decode_opt_box_autoadd_twonly_safe_backup(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -827,6 +847,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
StoryAudience sse_decode_story_audience(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
ThemeMode sse_decode_theme_mode(SseDeserializer deserializer);
|
||||
|
||||
|
|
@ -1013,6 +1036,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_story_audience(
|
||||
StoryAudience self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_twonly_safe_backup(
|
||||
TwonlySafeBackup self,
|
||||
|
|
@ -1280,6 +1309,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_story_audience(
|
||||
StoryAudience? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_twonly_safe_backup(
|
||||
TwonlySafeBackup? self,
|
||||
|
|
@ -1397,6 +1432,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_story_audience(StoryAudience self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_theme_mode(ThemeMode self, SseSerializer serializer);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import 'bridge/api.dart';
|
|||
import 'bridge/callbacks.dart';
|
||||
import 'bridge/groups.dart';
|
||||
import 'bridge/logging.dart';
|
||||
import 'bridge/stories.dart';
|
||||
import 'bridge/user_config.dart';
|
||||
import 'bridge/webxdc.dart';
|
||||
import 'bridge/wrapper.dart';
|
||||
|
|
@ -122,6 +123,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
PasswordlessRecoveryConfig
|
||||
dco_decode_box_autoadd_passwordless_recovery_config(dynamic raw);
|
||||
|
||||
@protected
|
||||
StoryAudience dco_decode_box_autoadd_story_audience(dynamic raw);
|
||||
|
||||
@protected
|
||||
TwonlySafeBackup dco_decode_box_autoadd_twonly_safe_backup(dynamic raw);
|
||||
|
||||
|
|
@ -300,6 +304,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
PasswordlessRecoveryConfig?
|
||||
dco_decode_opt_box_autoadd_passwordless_recovery_config(dynamic raw);
|
||||
|
||||
@protected
|
||||
StoryAudience? dco_decode_opt_box_autoadd_story_audience(dynamic raw);
|
||||
|
||||
@protected
|
||||
TwonlySafeBackup? dco_decode_opt_box_autoadd_twonly_safe_backup(dynamic raw);
|
||||
|
||||
|
|
@ -378,6 +385,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
SqlValue dco_decode_sql_value(dynamic raw);
|
||||
|
||||
@protected
|
||||
StoryAudience dco_decode_story_audience(dynamic raw);
|
||||
|
||||
@protected
|
||||
ThemeMode dco_decode_theme_mode(dynamic raw);
|
||||
|
||||
|
|
@ -523,6 +533,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
StoryAudience sse_decode_box_autoadd_story_audience(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
TwonlySafeBackup sse_decode_box_autoadd_twonly_safe_backup(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -735,6 +750,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
StoryAudience? sse_decode_opt_box_autoadd_story_audience(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
TwonlySafeBackup? sse_decode_opt_box_autoadd_twonly_safe_backup(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -829,6 +849,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
StoryAudience sse_decode_story_audience(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
ThemeMode sse_decode_theme_mode(SseDeserializer deserializer);
|
||||
|
||||
|
|
@ -1015,6 +1038,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_story_audience(
|
||||
StoryAudience self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_twonly_safe_backup(
|
||||
TwonlySafeBackup self,
|
||||
|
|
@ -1282,6 +1311,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_story_audience(
|
||||
StoryAudience? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_twonly_safe_backup(
|
||||
TwonlySafeBackup? self,
|
||||
|
|
@ -1399,6 +1434,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_story_audience(StoryAudience self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_theme_mode(ThemeMode self, SseSerializer serializer);
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,10 @@ class UserConfig {
|
|||
bool storeLocationInMemories;
|
||||
bool autoStoreAllSendUnlimitedMediaFiles;
|
||||
bool typingIndicators;
|
||||
|
||||
/// Announce a contact's first story in 24 hours. Checked only on this
|
||||
/// device, where the notification is created; senders always wake.
|
||||
bool storyNotifications;
|
||||
bool showRestoreFlame;
|
||||
|
||||
/// Unused: the best friend is derived from `total_media_counter` whenever
|
||||
|
|
@ -217,6 +221,7 @@ class UserConfig {
|
|||
required this.storeLocationInMemories,
|
||||
required this.autoStoreAllSendUnlimitedMediaFiles,
|
||||
required this.typingIndicators,
|
||||
required this.storyNotifications,
|
||||
required this.showRestoreFlame,
|
||||
this.myBestFriendGroupId,
|
||||
this.signalLastSignedPreKeyUpdated,
|
||||
|
|
@ -284,6 +289,7 @@ class UserConfig {
|
|||
storeLocationInMemories.hashCode ^
|
||||
autoStoreAllSendUnlimitedMediaFiles.hashCode ^
|
||||
typingIndicators.hashCode ^
|
||||
storyNotifications.hashCode ^
|
||||
showRestoreFlame.hashCode ^
|
||||
myBestFriendGroupId.hashCode ^
|
||||
signalLastSignedPreKeyUpdated.hashCode ^
|
||||
|
|
@ -356,6 +362,7 @@ class UserConfig {
|
|||
autoStoreAllSendUnlimitedMediaFiles ==
|
||||
other.autoStoreAllSendUnlimitedMediaFiles &&
|
||||
typingIndicators == other.typingIndicators &&
|
||||
storyNotifications == other.storyNotifications &&
|
||||
showRestoreFlame == other.showRestoreFlame &&
|
||||
myBestFriendGroupId == other.myBestFriendGroupId &&
|
||||
signalLastSignedPreKeyUpdated ==
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
|||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'package:twonly/app.dart';
|
||||
import 'package:twonly/core/bridge.dart' as bridge;
|
||||
import 'package:twonly/core/bridge/stories.dart' as stories;
|
||||
import 'package:twonly/core/bridge/wrapper/app_database.dart';
|
||||
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
||||
import 'package:twonly/core/frb_generated.dart';
|
||||
|
|
@ -290,6 +291,7 @@ Future<void> postStartupTasks() async {
|
|||
unawaited(MemoriesService.prewarmCache());
|
||||
|
||||
// 1. Immediate background cleanup (Non-blocking for UI)
|
||||
unawaitedRustCall(stories.purgeExpiredStories(), 'purgeExpiredStories');
|
||||
await twonlyDB.messagesDao.purgeMessageTable();
|
||||
unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts());
|
||||
unawaited(MediaFileService.purgeTempFolder());
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ class Routes {
|
|||
|
||||
static String chatsMessages(String groupId) => '/chats/messages/$groupId';
|
||||
|
||||
/// A contact's story. Pass a media id as `extra` to start at that item.
|
||||
static String chatsStory(int contactId) => '/chats/story/$contactId';
|
||||
|
||||
/// The user's own story. Pass a media id as `extra` to start at that item.
|
||||
static const String chatsOwnStory = '/chats/own_story';
|
||||
|
||||
static String groupCreateSelectMember(String? groupId) =>
|
||||
'/group/create/select_member${groupId == null ? '' : '/$groupId'}';
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,17 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
|||
.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) {
|
||||
final query =
|
||||
select(contactGroupMembers).join([
|
||||
|
|
@ -132,6 +143,7 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
|||
required int backgroundColor,
|
||||
required bool showAsShortcut,
|
||||
required bool showAsLabel,
|
||||
required bool shareStories,
|
||||
String? emoji,
|
||||
}) {
|
||||
return into(contactGroups).insert(
|
||||
|
|
@ -142,6 +154,7 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
|||
backgroundColor: backgroundColor,
|
||||
showAsShortcut: Value(showAsShortcut),
|
||||
showAsLabel: Value(showAsLabel),
|
||||
shareStories: Value(shareStories),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -153,6 +166,7 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
|||
required int backgroundColor,
|
||||
required bool showAsShortcut,
|
||||
required bool showAsLabel,
|
||||
required bool shareStories,
|
||||
String? emoji,
|
||||
}) {
|
||||
return (update(contactGroups)..where((t) => t.id.equals(id)))
|
||||
|
|
@ -164,6 +178,7 @@ class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
|
|||
backgroundColor: Value(backgroundColor),
|
||||
showAsShortcut: Value(showAsShortcut),
|
||||
showAsLabel: Value(showAsLabel),
|
||||
shareStories: Value(shareStories),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows > 0);
|
||||
|
|
|
|||
|
|
@ -171,7 +171,11 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
db.messages.mediaId.equalsExp(mediaFiles.mediaId),
|
||||
useColumns: false,
|
||||
),
|
||||
])..where(db.messages.groupId.equals(groupId));
|
||||
])..where(
|
||||
db.messages.groupId.equals(groupId) &
|
||||
(db.messages.isStory.equals(false) |
|
||||
db.messages.mediaStored.equals(true)),
|
||||
);
|
||||
return query.map((row) => row.readTable(mediaFiles)).watch();
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +230,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
) AS message_rank
|
||||
FROM messages
|
||||
WHERE messages.media_id IS NOT NULL
|
||||
AND (messages.is_story = 0 OR messages.media_stored = 1)
|
||||
)
|
||||
SELECT DISTINCT media_files.*
|
||||
FROM media_files
|
||||
|
|
@ -254,6 +259,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
mediaFiles.storedFileHash.equals(hash) &
|
||||
db.messages.senderId.equals(senderId) &
|
||||
db.messages.isWidgetMedia.equals(false) &
|
||||
db.messages.isStory.equals(false) &
|
||||
db.messages.openedAt.isNull(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
// ignore: matching_super_parameters
|
||||
MessagesDao(super.db);
|
||||
|
||||
/// A story row stays out of the chat until somebody saves it; from then on
|
||||
/// it is an ordinary stored media message there.
|
||||
Expression<bool> get _visibleInChat =>
|
||||
messages.isStory.equals(false) | messages.mediaStored.equals(true);
|
||||
|
||||
Stream<List<Message>> watchMessageNotOpened(String groupId) {
|
||||
final query =
|
||||
select(messages).join([
|
||||
|
|
@ -42,6 +47,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
..where(
|
||||
messages.openedAt.isNull() &
|
||||
messages.isWidgetMedia.equals(false) &
|
||||
_visibleInChat &
|
||||
messages.groupId.equals(groupId) &
|
||||
messages.isDeletedFromSender.equals(false) &
|
||||
(messages.mediaId.isNull() |
|
||||
|
|
@ -65,6 +71,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
..where(
|
||||
messages.openedAt.isNull() &
|
||||
messages.isWidgetMedia.equals(false) &
|
||||
_visibleInChat &
|
||||
messages.isDeletedFromSender.equals(false) &
|
||||
(messages.mediaId.isNull() |
|
||||
mediaFiles.downloadState.isNull() |
|
||||
|
|
@ -92,6 +99,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
mediaFiles.type.equals(MediaType.audio.name).not() &
|
||||
messages.openedAt.isNull() &
|
||||
messages.isWidgetMedia.equals(false) &
|
||||
_visibleInChat &
|
||||
messages.groupId.equals(groupId) &
|
||||
messages.mediaId.isNotNull() &
|
||||
messages.senderId.isNotNull() &
|
||||
|
|
@ -111,6 +119,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
])..where(
|
||||
messages.openedAt.isNull() &
|
||||
messages.isWidgetMedia.equals(false) &
|
||||
_visibleInChat &
|
||||
messages.mediaId.isNotNull() &
|
||||
messages.type.equals(MessageType.media.name) &
|
||||
mediaFiles.downloadState.equals(DownloadState.ready.name) &
|
||||
|
|
@ -136,6 +145,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
])
|
||||
..where(
|
||||
messages.groupId.equals(groupId) &
|
||||
_visibleInChat &
|
||||
// messages in groups will only be removed in case all members have received it...
|
||||
// so ensuring that this message is not shown in the messages anymore
|
||||
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
||||
|
|
@ -169,7 +179,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
FROM messages
|
||||
INNER JOIN groups ON groups.group_id = messages.group_id
|
||||
LEFT JOIN media_files ON media_files.media_id = messages.media_id
|
||||
WHERE (
|
||||
WHERE (messages.is_story = 0 OR messages.media_stored = 1)
|
||||
AND (
|
||||
messages.opened_at IS NULL OR
|
||||
messages.media_stored = 1 OR
|
||||
messages.opened_at > CAST(strftime('%s', 'now') AS INTEGER) -
|
||||
|
|
@ -207,6 +218,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
])
|
||||
..where(
|
||||
messages.groupId.equals(groupId) &
|
||||
_visibleInChat &
|
||||
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
||||
messages.openedAt.isNull() |
|
||||
messages.mediaStored.equals(true) |
|
||||
|
|
@ -251,6 +263,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
])
|
||||
..where(
|
||||
messages.groupId.equals(groupId) &
|
||||
_visibleInChat &
|
||||
(messages.createdAt.isSmallerThanValue(before) |
|
||||
(messages.createdAt.equals(before) &
|
||||
messages.messageId.isSmallerThanValue(
|
||||
|
|
@ -319,6 +332,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
// without ever clearing the storage its origin holds, so
|
||||
// apps leave only when somebody deletes them.
|
||||
m.type.equals(MessageType.webxdcApp.name).not() &
|
||||
// Unsaved story rows expire with the story, not the chat.
|
||||
(m.isStory.equals(false) | m.mediaStored.equals(true)) &
|
||||
((m.mediaStored.equals(true) &
|
||||
m.isDeletedFromSender.equals(true)) |
|
||||
m.mediaStored.equals(false)) &
|
||||
|
|
@ -645,11 +660,13 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
await transaction(() async {
|
||||
// A one-time app is the chat's durable shared object. Keep its card (and
|
||||
// therefore its instance and update log) while removing the visible chat
|
||||
// history and every ordinary app.
|
||||
// history and every ordinary app. Unsaved story rows are not chat
|
||||
// history; they leave when their story expires.
|
||||
await customStatement(
|
||||
'''
|
||||
DELETE FROM messages
|
||||
WHERE group_id = ?
|
||||
AND (is_story = 0 OR media_stored = 1)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM webxdc_instances AS instance
|
||||
|
|
@ -671,6 +688,15 @@ class MessagesDao extends DatabaseAccessor<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) {
|
||||
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)]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
Stream<Reaction?> watchLastReactions(String groupId) {
|
||||
final query =
|
||||
(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)])
|
||||
..limit(1);
|
||||
return query.map((row) => row.readTable(reactions)).watchSingleOrNull();
|
||||
|
|
@ -145,6 +150,7 @@ Stream<Reaction?> watchLastReactions(String groupId) {
|
|||
) AS reaction_rank
|
||||
FROM reactions
|
||||
INNER JOIN messages ON messages.message_id = reactions.message_id
|
||||
WHERE messages.is_story = 0 OR messages.media_stored = 1
|
||||
) AS reaction_rows
|
||||
WHERE reaction_rank = 1
|
||||
''',
|
||||
|
|
|
|||
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 =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get showAsLabel => boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get shareStories => boolean().withDefault(const Constant(false))();
|
||||
IntColumn get usageCounter => integer().withDefault(const Constant(0))();
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ class Messages extends Table {
|
|||
BoolColumn get isWidgetMedia =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// A story item: hidden from the chat until saved, gone 24h after sending.
|
||||
BoolColumn get isStory => boolean().withDefault(const Constant(false))();
|
||||
|
||||
DateTimeColumn get openedAt => dateTime().nullable()();
|
||||
DateTimeColumn get openedByAll => dateTime().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:twonly/src/database/daos/mediafiles.dao.dart';
|
|||
import 'package:twonly/src/database/daos/messages.dao.dart';
|
||||
import 'package:twonly/src/database/daos/reactions.dao.dart';
|
||||
import 'package:twonly/src/database/daos/receipts.dao.dart';
|
||||
import 'package:twonly/src/database/daos/stories.dao.dart';
|
||||
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
|
||||
import 'package:twonly/src/database/rust_change_notifier.dart';
|
||||
import 'package:twonly/src/database/rust_query_executor.dart';
|
||||
|
|
@ -63,6 +64,7 @@ part 'twonly.db.g.dart';
|
|||
UserDiscoveryDao,
|
||||
KeyVerificationDao,
|
||||
ContactGroupsDao,
|
||||
StoriesDao,
|
||||
],
|
||||
)
|
||||
class TwonlyDB extends _$TwonlyDB {
|
||||
|
|
|
|||
|
|
@ -5378,6 +5378,21 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
|||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _isStoryMeta = const VerificationMeta(
|
||||
'isStory',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<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(
|
||||
'openedAt',
|
||||
);
|
||||
|
|
@ -5460,6 +5475,7 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
|||
quotesMessageId,
|
||||
isDeletedFromSender,
|
||||
isWidgetMedia,
|
||||
isStory,
|
||||
openedAt,
|
||||
openedByAll,
|
||||
createdAt,
|
||||
|
|
@ -5584,6 +5600,12 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
|||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('is_story')) {
|
||||
context.handle(
|
||||
_isStoryMeta,
|
||||
isStory.isAcceptableOrUnknown(data['is_story']!, _isStoryMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('opened_at')) {
|
||||
context.handle(
|
||||
_openedAtMeta,
|
||||
|
|
@ -5687,6 +5709,10 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
|||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_widget_media'],
|
||||
)!,
|
||||
isStory: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_story'],
|
||||
)!,
|
||||
openedAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}opened_at'],
|
||||
|
|
@ -5734,6 +5760,9 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
final String? quotesMessageId;
|
||||
final bool isDeletedFromSender;
|
||||
final bool isWidgetMedia;
|
||||
|
||||
/// A story item: hidden from the chat until saved, gone 24h after sending.
|
||||
final bool isStory;
|
||||
final DateTime? openedAt;
|
||||
final DateTime? openedByAll;
|
||||
final DateTime createdAt;
|
||||
|
|
@ -5754,6 +5783,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
this.quotesMessageId,
|
||||
required this.isDeletedFromSender,
|
||||
required this.isWidgetMedia,
|
||||
required this.isStory,
|
||||
this.openedAt,
|
||||
this.openedByAll,
|
||||
required this.createdAt,
|
||||
|
|
@ -5791,6 +5821,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
}
|
||||
map['is_deleted_from_sender'] = Variable<bool>(isDeletedFromSender);
|
||||
map['is_widget_media'] = Variable<bool>(isWidgetMedia);
|
||||
map['is_story'] = Variable<bool>(isStory);
|
||||
if (!nullToAbsent || openedAt != null) {
|
||||
map['opened_at'] = Variable<DateTime>(openedAt);
|
||||
}
|
||||
|
|
@ -5837,6 +5868,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
: Value(quotesMessageId),
|
||||
isDeletedFromSender: Value(isDeletedFromSender),
|
||||
isWidgetMedia: Value(isWidgetMedia),
|
||||
isStory: Value(isStory),
|
||||
openedAt: openedAt == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(openedAt),
|
||||
|
|
@ -5879,6 +5911,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
json['isDeletedFromSender'],
|
||||
),
|
||||
isWidgetMedia: serializer.fromJson<bool>(json['isWidgetMedia']),
|
||||
isStory: serializer.fromJson<bool>(json['isStory']),
|
||||
openedAt: serializer.fromJson<DateTime?>(json['openedAt']),
|
||||
openedByAll: serializer.fromJson<DateTime?>(json['openedByAll']),
|
||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||
|
|
@ -5906,6 +5939,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
'quotesMessageId': serializer.toJson<String?>(quotesMessageId),
|
||||
'isDeletedFromSender': serializer.toJson<bool>(isDeletedFromSender),
|
||||
'isWidgetMedia': serializer.toJson<bool>(isWidgetMedia),
|
||||
'isStory': serializer.toJson<bool>(isStory),
|
||||
'openedAt': serializer.toJson<DateTime?>(openedAt),
|
||||
'openedByAll': serializer.toJson<DateTime?>(openedByAll),
|
||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||
|
|
@ -5929,6 +5963,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
Value<String?> quotesMessageId = const Value.absent(),
|
||||
bool? isDeletedFromSender,
|
||||
bool? isWidgetMedia,
|
||||
bool? isStory,
|
||||
Value<DateTime?> openedAt = const Value.absent(),
|
||||
Value<DateTime?> openedByAll = const Value.absent(),
|
||||
DateTime? createdAt,
|
||||
|
|
@ -5955,6 +5990,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
: this.quotesMessageId,
|
||||
isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender,
|
||||
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
||||
isStory: isStory ?? this.isStory,
|
||||
openedAt: openedAt.present ? openedAt.value : this.openedAt,
|
||||
openedByAll: openedByAll.present ? openedByAll.value : this.openedByAll,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
|
|
@ -5991,6 +6027,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
isWidgetMedia: data.isWidgetMedia.present
|
||||
? data.isWidgetMedia.value
|
||||
: this.isWidgetMedia,
|
||||
isStory: data.isStory.present ? data.isStory.value : this.isStory,
|
||||
openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt,
|
||||
openedByAll: data.openedByAll.present
|
||||
? data.openedByAll.value
|
||||
|
|
@ -6022,6 +6059,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
..write('quotesMessageId: $quotesMessageId, ')
|
||||
..write('isDeletedFromSender: $isDeletedFromSender, ')
|
||||
..write('isWidgetMedia: $isWidgetMedia, ')
|
||||
..write('isStory: $isStory, ')
|
||||
..write('openedAt: $openedAt, ')
|
||||
..write('openedByAll: $openedByAll, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
|
|
@ -6047,6 +6085,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
quotesMessageId,
|
||||
isDeletedFromSender,
|
||||
isWidgetMedia,
|
||||
isStory,
|
||||
openedAt,
|
||||
openedByAll,
|
||||
createdAt,
|
||||
|
|
@ -6074,6 +6113,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
other.quotesMessageId == this.quotesMessageId &&
|
||||
other.isDeletedFromSender == this.isDeletedFromSender &&
|
||||
other.isWidgetMedia == this.isWidgetMedia &&
|
||||
other.isStory == this.isStory &&
|
||||
other.openedAt == this.openedAt &&
|
||||
other.openedByAll == this.openedByAll &&
|
||||
other.createdAt == this.createdAt &&
|
||||
|
|
@ -6096,6 +6136,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
final Value<String?> quotesMessageId;
|
||||
final Value<bool> isDeletedFromSender;
|
||||
final Value<bool> isWidgetMedia;
|
||||
final Value<bool> isStory;
|
||||
final Value<DateTime?> openedAt;
|
||||
final Value<DateTime?> openedByAll;
|
||||
final Value<DateTime> createdAt;
|
||||
|
|
@ -6117,6 +6158,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
this.quotesMessageId = const Value.absent(),
|
||||
this.isDeletedFromSender = const Value.absent(),
|
||||
this.isWidgetMedia = const Value.absent(),
|
||||
this.isStory = const Value.absent(),
|
||||
this.openedAt = const Value.absent(),
|
||||
this.openedByAll = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
|
|
@ -6139,6 +6181,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
this.quotesMessageId = const Value.absent(),
|
||||
this.isDeletedFromSender = const Value.absent(),
|
||||
this.isWidgetMedia = const Value.absent(),
|
||||
this.isStory = const Value.absent(),
|
||||
this.openedAt = const Value.absent(),
|
||||
this.openedByAll = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
|
|
@ -6163,6 +6206,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
Expression<String>? quotesMessageId,
|
||||
Expression<bool>? isDeletedFromSender,
|
||||
Expression<bool>? isWidgetMedia,
|
||||
Expression<bool>? isStory,
|
||||
Expression<DateTime>? openedAt,
|
||||
Expression<DateTime>? openedByAll,
|
||||
Expression<DateTime>? createdAt,
|
||||
|
|
@ -6187,6 +6231,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
if (isDeletedFromSender != null)
|
||||
'is_deleted_from_sender': isDeletedFromSender,
|
||||
if (isWidgetMedia != null) 'is_widget_media': isWidgetMedia,
|
||||
if (isStory != null) 'is_story': isStory,
|
||||
if (openedAt != null) 'opened_at': openedAt,
|
||||
if (openedByAll != null) 'opened_by_all': openedByAll,
|
||||
if (createdAt != null) 'created_at': createdAt,
|
||||
|
|
@ -6211,6 +6256,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
Value<String?>? quotesMessageId,
|
||||
Value<bool>? isDeletedFromSender,
|
||||
Value<bool>? isWidgetMedia,
|
||||
Value<bool>? isStory,
|
||||
Value<DateTime?>? openedAt,
|
||||
Value<DateTime?>? openedByAll,
|
||||
Value<DateTime>? createdAt,
|
||||
|
|
@ -6234,6 +6280,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
quotesMessageId: quotesMessageId ?? this.quotesMessageId,
|
||||
isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender,
|
||||
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
||||
isStory: isStory ?? this.isStory,
|
||||
openedAt: openedAt ?? this.openedAt,
|
||||
openedByAll: openedByAll ?? this.openedByAll,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
|
|
@ -6288,6 +6335,9 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
if (isWidgetMedia.present) {
|
||||
map['is_widget_media'] = Variable<bool>(isWidgetMedia.value);
|
||||
}
|
||||
if (isStory.present) {
|
||||
map['is_story'] = Variable<bool>(isStory.value);
|
||||
}
|
||||
if (openedAt.present) {
|
||||
map['opened_at'] = Variable<DateTime>(openedAt.value);
|
||||
}
|
||||
|
|
@ -6328,6 +6378,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
..write('quotesMessageId: $quotesMessageId, ')
|
||||
..write('isDeletedFromSender: $isDeletedFromSender, ')
|
||||
..write('isWidgetMedia: $isWidgetMedia, ')
|
||||
..write('isStory: $isStory, ')
|
||||
..write('openedAt: $openedAt, ')
|
||||
..write('openedByAll: $openedByAll, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
|
|
@ -12081,6 +12132,21 @@ class $ContactGroupsTable extends ContactGroups
|
|||
),
|
||||
defaultValue: const Constant(true),
|
||||
);
|
||||
static const VerificationMeta _shareStoriesMeta = const VerificationMeta(
|
||||
'shareStories',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<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(
|
||||
'usageCounter',
|
||||
);
|
||||
|
|
@ -12114,6 +12180,7 @@ class $ContactGroupsTable extends ContactGroups
|
|||
backgroundColor,
|
||||
showAsShortcut,
|
||||
showAsLabel,
|
||||
shareStories,
|
||||
usageCounter,
|
||||
createdAt,
|
||||
];
|
||||
|
|
@ -12183,6 +12250,15 @@ class $ContactGroupsTable extends ContactGroups
|
|||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('share_stories')) {
|
||||
context.handle(
|
||||
_shareStoriesMeta,
|
||||
shareStories.isAcceptableOrUnknown(
|
||||
data['share_stories']!,
|
||||
_shareStoriesMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('usage_counter')) {
|
||||
context.handle(
|
||||
_usageCounterMeta,
|
||||
|
|
@ -12235,6 +12311,10 @@ class $ContactGroupsTable extends ContactGroups
|
|||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}show_as_label'],
|
||||
)!,
|
||||
shareStories: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}share_stories'],
|
||||
)!,
|
||||
usageCounter: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}usage_counter'],
|
||||
|
|
@ -12260,6 +12340,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
final int backgroundColor;
|
||||
final bool showAsShortcut;
|
||||
final bool showAsLabel;
|
||||
final bool shareStories;
|
||||
final int usageCounter;
|
||||
final DateTime createdAt;
|
||||
const ContactGroup({
|
||||
|
|
@ -12270,6 +12351,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
required this.backgroundColor,
|
||||
required this.showAsShortcut,
|
||||
required this.showAsLabel,
|
||||
required this.shareStories,
|
||||
required this.usageCounter,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
|
@ -12285,6 +12367,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
map['background_color'] = Variable<int>(backgroundColor);
|
||||
map['show_as_shortcut'] = Variable<bool>(showAsShortcut);
|
||||
map['show_as_label'] = Variable<bool>(showAsLabel);
|
||||
map['share_stories'] = Variable<bool>(shareStories);
|
||||
map['usage_counter'] = Variable<int>(usageCounter);
|
||||
map['created_at'] = Variable<DateTime>(createdAt);
|
||||
return map;
|
||||
|
|
@ -12301,6 +12384,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
backgroundColor: Value(backgroundColor),
|
||||
showAsShortcut: Value(showAsShortcut),
|
||||
showAsLabel: Value(showAsLabel),
|
||||
shareStories: Value(shareStories),
|
||||
usageCounter: Value(usageCounter),
|
||||
createdAt: Value(createdAt),
|
||||
);
|
||||
|
|
@ -12319,6 +12403,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
backgroundColor: serializer.fromJson<int>(json['backgroundColor']),
|
||||
showAsShortcut: serializer.fromJson<bool>(json['showAsShortcut']),
|
||||
showAsLabel: serializer.fromJson<bool>(json['showAsLabel']),
|
||||
shareStories: serializer.fromJson<bool>(json['shareStories']),
|
||||
usageCounter: serializer.fromJson<int>(json['usageCounter']),
|
||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||
);
|
||||
|
|
@ -12334,6 +12419,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
'backgroundColor': serializer.toJson<int>(backgroundColor),
|
||||
'showAsShortcut': serializer.toJson<bool>(showAsShortcut),
|
||||
'showAsLabel': serializer.toJson<bool>(showAsLabel),
|
||||
'shareStories': serializer.toJson<bool>(shareStories),
|
||||
'usageCounter': serializer.toJson<int>(usageCounter),
|
||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||
};
|
||||
|
|
@ -12347,6 +12433,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
int? backgroundColor,
|
||||
bool? showAsShortcut,
|
||||
bool? showAsLabel,
|
||||
bool? shareStories,
|
||||
int? usageCounter,
|
||||
DateTime? createdAt,
|
||||
}) => ContactGroup(
|
||||
|
|
@ -12357,6 +12444,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
showAsShortcut: showAsShortcut ?? this.showAsShortcut,
|
||||
showAsLabel: showAsLabel ?? this.showAsLabel,
|
||||
shareStories: shareStories ?? this.shareStories,
|
||||
usageCounter: usageCounter ?? this.usageCounter,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
|
|
@ -12375,6 +12463,9 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
showAsLabel: data.showAsLabel.present
|
||||
? data.showAsLabel.value
|
||||
: this.showAsLabel,
|
||||
shareStories: data.shareStories.present
|
||||
? data.shareStories.value
|
||||
: this.shareStories,
|
||||
usageCounter: data.usageCounter.present
|
||||
? data.usageCounter.value
|
||||
: this.usageCounter,
|
||||
|
|
@ -12392,6 +12483,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
..write('backgroundColor: $backgroundColor, ')
|
||||
..write('showAsShortcut: $showAsShortcut, ')
|
||||
..write('showAsLabel: $showAsLabel, ')
|
||||
..write('shareStories: $shareStories, ')
|
||||
..write('usageCounter: $usageCounter, ')
|
||||
..write('createdAt: $createdAt')
|
||||
..write(')'))
|
||||
|
|
@ -12407,6 +12499,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
backgroundColor,
|
||||
showAsShortcut,
|
||||
showAsLabel,
|
||||
shareStories,
|
||||
usageCounter,
|
||||
createdAt,
|
||||
);
|
||||
|
|
@ -12421,6 +12514,7 @@ class ContactGroup extends DataClass implements Insertable<ContactGroup> {
|
|||
other.backgroundColor == this.backgroundColor &&
|
||||
other.showAsShortcut == this.showAsShortcut &&
|
||||
other.showAsLabel == this.showAsLabel &&
|
||||
other.shareStories == this.shareStories &&
|
||||
other.usageCounter == this.usageCounter &&
|
||||
other.createdAt == this.createdAt);
|
||||
}
|
||||
|
|
@ -12433,6 +12527,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
|||
final Value<int> backgroundColor;
|
||||
final Value<bool> showAsShortcut;
|
||||
final Value<bool> showAsLabel;
|
||||
final Value<bool> shareStories;
|
||||
final Value<int> usageCounter;
|
||||
final Value<DateTime> createdAt;
|
||||
const ContactGroupsCompanion({
|
||||
|
|
@ -12443,6 +12538,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
|||
this.backgroundColor = const Value.absent(),
|
||||
this.showAsShortcut = const Value.absent(),
|
||||
this.showAsLabel = const Value.absent(),
|
||||
this.shareStories = const Value.absent(),
|
||||
this.usageCounter = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
});
|
||||
|
|
@ -12454,6 +12550,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
|||
required int backgroundColor,
|
||||
this.showAsShortcut = const Value.absent(),
|
||||
this.showAsLabel = const Value.absent(),
|
||||
this.shareStories = const Value.absent(),
|
||||
this.usageCounter = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
}) : name = Value(name),
|
||||
|
|
@ -12467,6 +12564,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
|||
Expression<int>? backgroundColor,
|
||||
Expression<bool>? showAsShortcut,
|
||||
Expression<bool>? showAsLabel,
|
||||
Expression<bool>? shareStories,
|
||||
Expression<int>? usageCounter,
|
||||
Expression<DateTime>? createdAt,
|
||||
}) {
|
||||
|
|
@ -12478,6 +12576,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
|||
if (backgroundColor != null) 'background_color': backgroundColor,
|
||||
if (showAsShortcut != null) 'show_as_shortcut': showAsShortcut,
|
||||
if (showAsLabel != null) 'show_as_label': showAsLabel,
|
||||
if (shareStories != null) 'share_stories': shareStories,
|
||||
if (usageCounter != null) 'usage_counter': usageCounter,
|
||||
if (createdAt != null) 'created_at': createdAt,
|
||||
});
|
||||
|
|
@ -12491,6 +12590,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
|||
Value<int>? backgroundColor,
|
||||
Value<bool>? showAsShortcut,
|
||||
Value<bool>? showAsLabel,
|
||||
Value<bool>? shareStories,
|
||||
Value<int>? usageCounter,
|
||||
Value<DateTime>? createdAt,
|
||||
}) {
|
||||
|
|
@ -12502,6 +12602,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
|||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
showAsShortcut: showAsShortcut ?? this.showAsShortcut,
|
||||
showAsLabel: showAsLabel ?? this.showAsLabel,
|
||||
shareStories: shareStories ?? this.shareStories,
|
||||
usageCounter: usageCounter ?? this.usageCounter,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
|
|
@ -12531,6 +12632,9 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
|||
if (showAsLabel.present) {
|
||||
map['show_as_label'] = Variable<bool>(showAsLabel.value);
|
||||
}
|
||||
if (shareStories.present) {
|
||||
map['share_stories'] = Variable<bool>(shareStories.value);
|
||||
}
|
||||
if (usageCounter.present) {
|
||||
map['usage_counter'] = Variable<int>(usageCounter.value);
|
||||
}
|
||||
|
|
@ -12550,6 +12654,7 @@ class ContactGroupsCompanion extends UpdateCompanion<ContactGroup> {
|
|||
..write('backgroundColor: $backgroundColor, ')
|
||||
..write('showAsShortcut: $showAsShortcut, ')
|
||||
..write('showAsLabel: $showAsLabel, ')
|
||||
..write('shareStories: $shareStories, ')
|
||||
..write('usageCounter: $usageCounter, ')
|
||||
..write('createdAt: $createdAt')
|
||||
..write(')'))
|
||||
|
|
@ -14928,6 +15033,7 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
|
|||
late final ContactGroupsDao contactGroupsDao = ContactGroupsDao(
|
||||
this as TwonlyDB,
|
||||
);
|
||||
late final StoriesDao storiesDao = StoriesDao(this as TwonlyDB);
|
||||
@override
|
||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
|
|
@ -16713,7 +16819,7 @@ class $$ContactsTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$ContactsTable, Contact>(table),
|
||||
$$ContactsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -18000,8 +18106,10 @@ class $$GroupsTableTableManager
|
|||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) =>
|
||||
(e.readTable(table), $$GroupsTableReferences(db, table, e)),
|
||||
(e) => (
|
||||
e.readTable<$GroupsTable, Group>(table),
|
||||
$$GroupsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
prefetchHooksCallback:
|
||||
|
|
@ -18996,7 +19104,7 @@ class $$MediaFilesTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$MediaFilesTable, MediaFile>(table),
|
||||
$$MediaFilesTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -19066,6 +19174,7 @@ typedef $$MessagesTableCreateCompanionBuilder =
|
|||
Value<String?> quotesMessageId,
|
||||
Value<bool> isDeletedFromSender,
|
||||
Value<bool> isWidgetMedia,
|
||||
Value<bool> isStory,
|
||||
Value<DateTime?> openedAt,
|
||||
Value<DateTime?> openedByAll,
|
||||
Value<DateTime> createdAt,
|
||||
|
|
@ -19089,6 +19198,7 @@ typedef $$MessagesTableUpdateCompanionBuilder =
|
|||
Value<String?> quotesMessageId,
|
||||
Value<bool> isDeletedFromSender,
|
||||
Value<bool> isWidgetMedia,
|
||||
Value<bool> isStory,
|
||||
Value<DateTime?> openedAt,
|
||||
Value<DateTime?> openedByAll,
|
||||
Value<DateTime> createdAt,
|
||||
|
|
@ -19314,6 +19424,11 @@ class $$MessagesTableFilterComposer
|
|||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get isStory => $composableBuilder(
|
||||
column: $table.isStory,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<DateTime> get openedAt => $composableBuilder(
|
||||
column: $table.openedAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
|
|
@ -19598,6 +19713,11 @@ class $$MessagesTableOrderingComposer
|
|||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get isStory => $composableBuilder(
|
||||
column: $table.isStory,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<DateTime> get openedAt => $composableBuilder(
|
||||
column: $table.openedAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
|
|
@ -19751,6 +19871,9 @@ class $$MessagesTableAnnotationComposer
|
|||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get isStory =>
|
||||
$composableBuilder(column: $table.isStory, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get openedAt =>
|
||||
$composableBuilder(column: $table.openedAt, builder: (column) => column);
|
||||
|
||||
|
|
@ -20020,6 +20143,7 @@ class $$MessagesTableTableManager
|
|||
Value<String?> quotesMessageId = const Value.absent(),
|
||||
Value<bool> isDeletedFromSender = const Value.absent(),
|
||||
Value<bool> isWidgetMedia = const Value.absent(),
|
||||
Value<bool> isStory = const Value.absent(),
|
||||
Value<DateTime?> openedAt = const Value.absent(),
|
||||
Value<DateTime?> openedByAll = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
|
|
@ -20041,6 +20165,7 @@ class $$MessagesTableTableManager
|
|||
quotesMessageId: quotesMessageId,
|
||||
isDeletedFromSender: isDeletedFromSender,
|
||||
isWidgetMedia: isWidgetMedia,
|
||||
isStory: isStory,
|
||||
openedAt: openedAt,
|
||||
openedByAll: openedByAll,
|
||||
createdAt: createdAt,
|
||||
|
|
@ -20064,6 +20189,7 @@ class $$MessagesTableTableManager
|
|||
Value<String?> quotesMessageId = const Value.absent(),
|
||||
Value<bool> isDeletedFromSender = const Value.absent(),
|
||||
Value<bool> isWidgetMedia = const Value.absent(),
|
||||
Value<bool> isStory = const Value.absent(),
|
||||
Value<DateTime?> openedAt = const Value.absent(),
|
||||
Value<DateTime?> openedByAll = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
|
|
@ -20085,6 +20211,7 @@ class $$MessagesTableTableManager
|
|||
quotesMessageId: quotesMessageId,
|
||||
isDeletedFromSender: isDeletedFromSender,
|
||||
isWidgetMedia: isWidgetMedia,
|
||||
isStory: isStory,
|
||||
openedAt: openedAt,
|
||||
openedByAll: openedByAll,
|
||||
createdAt: createdAt,
|
||||
|
|
@ -20096,7 +20223,7 @@ class $$MessagesTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$MessagesTable, Message>(table),
|
||||
$$MessagesTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -20643,7 +20770,7 @@ class $$MessageHistoriesTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$MessageHistoriesTable, MessageHistory>(table),
|
||||
$$MessageHistoriesTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -21031,7 +21158,7 @@ class $$ReactionsTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$ReactionsTable, Reaction>(table),
|
||||
$$ReactionsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -21503,7 +21630,7 @@ class $$GroupMembersTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$GroupMembersTable, GroupMember>(table),
|
||||
$$GroupMembersTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -22053,7 +22180,7 @@ class $$ReceiptsTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$ReceiptsTable, Receipt>(table),
|
||||
$$ReceiptsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -22251,7 +22378,16 @@ class $$ReceivedReceiptsTableTableManager
|
|||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable<$ReceivedReceiptsTable, ReceivedReceipt>(table),
|
||||
BaseReferences<
|
||||
_$TwonlyDB,
|
||||
$ReceivedReceiptsTable,
|
||||
ReceivedReceipt
|
||||
>(db, table, e),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
|
|
@ -22587,7 +22723,7 @@ class $$MessageActionsTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$MessageActionsTable, MessageAction>(table),
|
||||
$$MessageActionsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -23090,7 +23226,7 @@ class $$GroupHistoriesTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$GroupHistoriesTable, GroupHistory>(table),
|
||||
$$GroupHistoriesTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -23499,7 +23635,7 @@ class $$KeyVerificationsTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$KeyVerificationsTable, KeyVerification>(table),
|
||||
$$KeyVerificationsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -23721,7 +23857,18 @@ class $$VerificationTokensTableTableManager
|
|||
createdAt: createdAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable<$VerificationTokensTable, VerificationToken>(
|
||||
table,
|
||||
),
|
||||
BaseReferences<
|
||||
_$TwonlyDB,
|
||||
$VerificationTokensTable,
|
||||
VerificationToken
|
||||
>(db, table, e),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
|
|
@ -24076,7 +24223,10 @@ class $$UserDiscoveryAnnouncedUsersTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<
|
||||
$UserDiscoveryAnnouncedUsersTable,
|
||||
UserDiscoveryAnnouncedUser
|
||||
>(table),
|
||||
$$UserDiscoveryAnnouncedUsersTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -24463,7 +24613,10 @@ class $$UserDiscoveryUserRelationsTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<
|
||||
$UserDiscoveryUserRelationsTable,
|
||||
UserDiscoveryUserRelation
|
||||
>(table),
|
||||
$$UserDiscoveryUserRelationsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -24851,7 +25004,10 @@ class $$UserDiscoveryOtherPromotionsTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<
|
||||
$UserDiscoveryOtherPromotionsTable,
|
||||
UserDiscoveryOtherPromotion
|
||||
>(table),
|
||||
$$UserDiscoveryOtherPromotionsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -25154,7 +25310,10 @@ class $$UserDiscoveryOwnPromotionsTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<
|
||||
$UserDiscoveryOwnPromotionsTable,
|
||||
UserDiscoveryOwnPromotion
|
||||
>(table),
|
||||
$$UserDiscoveryOwnPromotionsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -25446,7 +25605,9 @@ class $$UserDiscoverySharesTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$UserDiscoverySharesTable, UserDiscoveryShare>(
|
||||
table,
|
||||
),
|
||||
$$UserDiscoverySharesTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -25521,6 +25682,7 @@ typedef $$ContactGroupsTableCreateCompanionBuilder =
|
|||
required int backgroundColor,
|
||||
Value<bool> showAsShortcut,
|
||||
Value<bool> showAsLabel,
|
||||
Value<bool> shareStories,
|
||||
Value<int> usageCounter,
|
||||
Value<DateTime> createdAt,
|
||||
});
|
||||
|
|
@ -25533,6 +25695,7 @@ typedef $$ContactGroupsTableUpdateCompanionBuilder =
|
|||
Value<int> backgroundColor,
|
||||
Value<bool> showAsShortcut,
|
||||
Value<bool> showAsLabel,
|
||||
Value<bool> shareStories,
|
||||
Value<int> usageCounter,
|
||||
Value<DateTime> createdAt,
|
||||
});
|
||||
|
|
@ -25613,6 +25776,11 @@ class $$ContactGroupsTableFilterComposer
|
|||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get shareStories => $composableBuilder(
|
||||
column: $table.shareStories,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get usageCounter => $composableBuilder(
|
||||
column: $table.usageCounter,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
|
|
@ -25693,6 +25861,11 @@ class $$ContactGroupsTableOrderingComposer
|
|||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get shareStories => $composableBuilder(
|
||||
column: $table.shareStories,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get usageCounter => $composableBuilder(
|
||||
column: $table.usageCounter,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
|
|
@ -25740,6 +25913,11 @@ class $$ContactGroupsTableAnnotationComposer
|
|||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get shareStories => $composableBuilder(
|
||||
column: $table.shareStories,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get usageCounter => $composableBuilder(
|
||||
column: $table.usageCounter,
|
||||
builder: (column) => column,
|
||||
|
|
@ -25810,6 +25988,7 @@ class $$ContactGroupsTableTableManager
|
|||
Value<int> backgroundColor = const Value.absent(),
|
||||
Value<bool> showAsShortcut = const Value.absent(),
|
||||
Value<bool> showAsLabel = const Value.absent(),
|
||||
Value<bool> shareStories = const Value.absent(),
|
||||
Value<int> usageCounter = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
}) => ContactGroupsCompanion(
|
||||
|
|
@ -25820,6 +25999,7 @@ class $$ContactGroupsTableTableManager
|
|||
backgroundColor: backgroundColor,
|
||||
showAsShortcut: showAsShortcut,
|
||||
showAsLabel: showAsLabel,
|
||||
shareStories: shareStories,
|
||||
usageCounter: usageCounter,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
|
|
@ -25832,6 +26012,7 @@ class $$ContactGroupsTableTableManager
|
|||
required int backgroundColor,
|
||||
Value<bool> showAsShortcut = const Value.absent(),
|
||||
Value<bool> showAsLabel = const Value.absent(),
|
||||
Value<bool> shareStories = const Value.absent(),
|
||||
Value<int> usageCounter = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
}) => ContactGroupsCompanion.insert(
|
||||
|
|
@ -25842,13 +26023,14 @@ class $$ContactGroupsTableTableManager
|
|||
backgroundColor: backgroundColor,
|
||||
showAsShortcut: showAsShortcut,
|
||||
showAsLabel: showAsLabel,
|
||||
shareStories: shareStories,
|
||||
usageCounter: usageCounter,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$ContactGroupsTable, ContactGroup>(table),
|
||||
$$ContactGroupsTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -26304,7 +26486,9 @@ class $$ContactGroupMembersTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$ContactGroupMembersTable, ContactGroupMember>(
|
||||
table,
|
||||
),
|
||||
$$ContactGroupMembersTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -26755,7 +26939,16 @@ class $$WebxdcAppsTableTableManager
|
|||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable<$WebxdcAppsTable, WebxdcApp>(table),
|
||||
BaseReferences<_$TwonlyDB, $WebxdcAppsTable, WebxdcApp>(
|
||||
db,
|
||||
table,
|
||||
e,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
|
|
@ -27207,7 +27400,7 @@ class $$WebxdcInstancesTableTableManager
|
|||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable(table),
|
||||
e.readTable<$WebxdcInstancesTable, WebxdcInstance>(table),
|
||||
$$WebxdcInstancesTableReferences(db, table, e),
|
||||
),
|
||||
)
|
||||
|
|
@ -27510,7 +27703,16 @@ class $$WebxdcUpdatesTableTableManager
|
|||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable<$WebxdcUpdatesTable, WebxdcUpdate>(table),
|
||||
BaseReferences<_$TwonlyDB, $WebxdcUpdatesTable, WebxdcUpdate>(
|
||||
db,
|
||||
table,
|
||||
e,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4857,6 +4857,144 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'This link opens outside twonly, in your browser:'**
|
||||
String get webxdcExternalLinkBody;
|
||||
|
||||
/// No description provided for @notificationStory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'posted a story.'**
|
||||
String get notificationStory;
|
||||
|
||||
/// No description provided for @settingsNotifyStories.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Story notifications'**
|
||||
String get settingsNotifyStories;
|
||||
|
||||
/// No description provided for @settingsNotifyStoriesSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Be notified of the first story of the day from contacts you\'ve written with in the last two weeks.'**
|
||||
String get settingsNotifyStoriesSubtitle;
|
||||
|
||||
/// No description provided for @contactGroupShareStories.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share stories'**
|
||||
String get contactGroupShareStories;
|
||||
|
||||
/// No description provided for @contactGroupShareStoriesSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offer this group as an audience when you post a story.'**
|
||||
String get contactGroupShareStoriesSubtitle;
|
||||
|
||||
/// No description provided for @contactGroupStoriesFeature.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stories'**
|
||||
String get contactGroupStoriesFeature;
|
||||
|
||||
/// No description provided for @story.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Story'**
|
||||
String get story;
|
||||
|
||||
/// No description provided for @storyExpired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Story expired'**
|
||||
String get storyExpired;
|
||||
|
||||
/// No description provided for @storyMine.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My story'**
|
||||
String get storyMine;
|
||||
|
||||
/// No description provided for @storyDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete story?'**
|
||||
String get storyDeleteTitle;
|
||||
|
||||
/// No description provided for @storyDeleteBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This removes it from your story.'**
|
||||
String get storyDeleteBody;
|
||||
|
||||
/// No description provided for @storyDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get storyDelete;
|
||||
|
||||
/// No description provided for @storyDeleteFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not delete it. Try again in a moment.'**
|
||||
String get storyDeleteFailed;
|
||||
|
||||
/// No description provided for @shareImageStory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Story'**
|
||||
String get shareImageStory;
|
||||
|
||||
/// No description provided for @shareImageStoryAudience.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Reaches 1 contact} other{Reaches {count} contacts}}'**
|
||||
String shareImageStoryAudience(int count);
|
||||
|
||||
/// No description provided for @shareImageStoryUnavailable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'twonly-protected media can\'t be posted as a story.'**
|
||||
String get shareImageStoryUnavailable;
|
||||
|
||||
/// No description provided for @shareImageStoryAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All contacts'**
|
||||
String get shareImageStoryAll;
|
||||
|
||||
/// No description provided for @shareImageStoryNewGroup.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New story group'**
|
||||
String get shareImageStoryNewGroup;
|
||||
|
||||
/// No description provided for @shareImageStoryFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not post the story. One send can reach at most {max} people.'**
|
||||
String shareImageStoryFailed(int max);
|
||||
|
||||
/// No description provided for @notificationStoryReply.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'has replied to your story.'**
|
||||
String get notificationStoryReply;
|
||||
|
||||
/// No description provided for @notificationStoryReaction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'has reacted with {reaction} to your story.'**
|
||||
String notificationStoryReaction(Object reaction);
|
||||
|
||||
/// No description provided for @notificationStoredStory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'has saved your story.'**
|
||||
String get notificationStoredStory;
|
||||
|
||||
/// No description provided for @storyViews.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{No views yet} =1{1 view} other{{count} views}}'**
|
||||
String storyViews(int count);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -2851,4 +2851,97 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||
@override
|
||||
String get webxdcExternalLinkBody =>
|
||||
'يُفتح هذا الرابط خارج twonly، في متصفحك:';
|
||||
|
||||
@override
|
||||
String get notificationStory => 'قصة جديدة.';
|
||||
|
||||
@override
|
||||
String get settingsNotifyStories => 'إشعارات القصص';
|
||||
|
||||
@override
|
||||
String get settingsNotifyStoriesSubtitle =>
|
||||
'تلقَّ إشعارًا بأول قصة في اليوم من جهات الاتصال التي راسلتها خلال الأسبوعين الماضيين.';
|
||||
|
||||
@override
|
||||
String get contactGroupShareStories => 'مشاركة القصص';
|
||||
|
||||
@override
|
||||
String get contactGroupShareStoriesSubtitle =>
|
||||
'اعرض هذه المجموعة كجمهور عند نشر قصة.';
|
||||
|
||||
@override
|
||||
String get contactGroupStoriesFeature => 'القصص';
|
||||
|
||||
@override
|
||||
String get story => 'قصة';
|
||||
|
||||
@override
|
||||
String get storyExpired => 'انتهت صلاحية القصة';
|
||||
|
||||
@override
|
||||
String get storyMine => 'قصتي';
|
||||
|
||||
@override
|
||||
String get storyDeleteTitle => 'حذف القصة؟';
|
||||
|
||||
@override
|
||||
String get storyDeleteBody => 'سيؤدي هذا إلى إزالتها من قصتك.';
|
||||
|
||||
@override
|
||||
String get storyDelete => 'حذف';
|
||||
|
||||
@override
|
||||
String get storyDeleteFailed => 'تعذّر الحذف. حاول مرة أخرى بعد قليل.';
|
||||
|
||||
@override
|
||||
String get shareImageStory => 'قصة';
|
||||
|
||||
@override
|
||||
String shareImageStoryAudience(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'تصل إلى $count من جهات الاتصال',
|
||||
one: 'تصل إلى جهة اتصال واحدة',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareImageStoryUnavailable =>
|
||||
'لا يمكن نشر الوسائط المحمية بـ twonly كقصة.';
|
||||
|
||||
@override
|
||||
String get shareImageStoryAll => 'كل جهات الاتصال';
|
||||
|
||||
@override
|
||||
String get shareImageStoryNewGroup => 'مجموعة قصص جديدة';
|
||||
|
||||
@override
|
||||
String shareImageStoryFailed(int max) {
|
||||
return 'تعذّر نشر القصة. يمكن أن يصل الإرسال الواحد إلى $max شخص كحد أقصى.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notificationStoryReply => 'رد على قصتك.';
|
||||
|
||||
@override
|
||||
String notificationStoryReaction(Object reaction) {
|
||||
return 'تفاعل $reaction مع قصتك.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notificationStoredStory => 'تم حفظ قصتك.';
|
||||
|
||||
@override
|
||||
String storyViews(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count مشاهدات',
|
||||
one: 'مشاهدة واحدة',
|
||||
zero: 'لا مشاهدات بعد',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2851,4 +2851,98 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get webxdcExternalLinkBody =>
|
||||
'Dieser Link öffnet sich außerhalb von twonly in deinem Browser:';
|
||||
|
||||
@override
|
||||
String get notificationStory => 'hat eine Story gepostet.';
|
||||
|
||||
@override
|
||||
String get settingsNotifyStories => 'Story-Benachrichtigungen';
|
||||
|
||||
@override
|
||||
String get settingsNotifyStoriesSubtitle =>
|
||||
'Werde über die erste Story des Tages von Kontakten benachrichtigt, mit denen du in den letzten zwei Wochen geschrieben hast.';
|
||||
|
||||
@override
|
||||
String get contactGroupShareStories => 'Stories teilen';
|
||||
|
||||
@override
|
||||
String get contactGroupShareStoriesSubtitle =>
|
||||
'Biete diese Gruppe als Publikum an, wenn du eine Story postest.';
|
||||
|
||||
@override
|
||||
String get contactGroupStoriesFeature => 'Stories';
|
||||
|
||||
@override
|
||||
String get story => 'Story';
|
||||
|
||||
@override
|
||||
String get storyExpired => 'Story abgelaufen';
|
||||
|
||||
@override
|
||||
String get storyMine => 'Meine Story';
|
||||
|
||||
@override
|
||||
String get storyDeleteTitle => 'Story löschen?';
|
||||
|
||||
@override
|
||||
String get storyDeleteBody => 'Dadurch wird sie aus deiner Story entfernt.';
|
||||
|
||||
@override
|
||||
String get storyDelete => 'Löschen';
|
||||
|
||||
@override
|
||||
String get storyDeleteFailed =>
|
||||
'Konnte nicht gelöscht werden. Versuche es gleich noch einmal.';
|
||||
|
||||
@override
|
||||
String get shareImageStory => 'Story';
|
||||
|
||||
@override
|
||||
String shareImageStoryAudience(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Erreicht $count Kontakte',
|
||||
one: 'Erreicht 1 Kontakt',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareImageStoryUnavailable =>
|
||||
'Mit twonly geschützte Medien können nicht als Story gepostet werden.';
|
||||
|
||||
@override
|
||||
String get shareImageStoryAll => 'Alle Kontakte';
|
||||
|
||||
@override
|
||||
String get shareImageStoryNewGroup => 'Neue Story-Gruppe';
|
||||
|
||||
@override
|
||||
String shareImageStoryFailed(int max) {
|
||||
return 'Die Story konnte nicht gepostet werden. Ein Versand erreicht höchstens $max Personen.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notificationStoryReply => 'hat auf deine Story geantwortet.';
|
||||
|
||||
@override
|
||||
String notificationStoryReaction(Object reaction) {
|
||||
return 'hat mit $reaction auf deine Story reagiert.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notificationStoredStory => 'hat deine Story gespeichert.';
|
||||
|
||||
@override
|
||||
String storyViews(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Aufrufe',
|
||||
one: '1 Aufruf',
|
||||
zero: 'Noch keine Aufrufe',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2823,4 +2823,97 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get webxdcExternalLinkBody =>
|
||||
'This link opens outside twonly, in your browser:';
|
||||
|
||||
@override
|
||||
String get notificationStory => 'posted a story.';
|
||||
|
||||
@override
|
||||
String get settingsNotifyStories => 'Story notifications';
|
||||
|
||||
@override
|
||||
String get settingsNotifyStoriesSubtitle =>
|
||||
'Be notified of the first story of the day from contacts you\'ve written with in the last two weeks.';
|
||||
|
||||
@override
|
||||
String get contactGroupShareStories => 'Share stories';
|
||||
|
||||
@override
|
||||
String get contactGroupShareStoriesSubtitle =>
|
||||
'Offer this group as an audience when you post a story.';
|
||||
|
||||
@override
|
||||
String get contactGroupStoriesFeature => 'Stories';
|
||||
|
||||
@override
|
||||
String get story => 'Story';
|
||||
|
||||
@override
|
||||
String get storyExpired => 'Story expired';
|
||||
|
||||
@override
|
||||
String get storyMine => 'My story';
|
||||
|
||||
@override
|
||||
String get storyDeleteTitle => 'Delete story?';
|
||||
|
||||
@override
|
||||
String get storyDeleteBody => 'This removes it from your story.';
|
||||
|
||||
@override
|
||||
String get storyDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get storyDeleteFailed => 'Could not delete it. Try again in a moment.';
|
||||
|
||||
@override
|
||||
String get shareImageStory => 'Story';
|
||||
|
||||
@override
|
||||
String shareImageStoryAudience(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Reaches $count contacts',
|
||||
one: 'Reaches 1 contact',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareImageStoryUnavailable =>
|
||||
'twonly-protected media can\'t be posted as a story.';
|
||||
|
||||
@override
|
||||
String get shareImageStoryAll => 'All contacts';
|
||||
|
||||
@override
|
||||
String get shareImageStoryNewGroup => 'New story group';
|
||||
|
||||
@override
|
||||
String shareImageStoryFailed(int max) {
|
||||
return 'Could not post the story. One send can reach at most $max people.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notificationStoryReply => 'has replied to your story.';
|
||||
|
||||
@override
|
||||
String notificationStoryReaction(Object reaction) {
|
||||
return 'has reacted with $reaction to your story.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notificationStoredStory => 'has saved your story.';
|
||||
|
||||
@override
|
||||
String storyViews(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count views',
|
||||
one: '1 view',
|
||||
zero: 'No views yet',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -792,5 +792,49 @@
|
|||
"webxdcUnavailable": "تعذّر تحميل هذا التطبيق.",
|
||||
"webxdcDeleteConfirm": "سيتم حذف كل ما حفظه هذا التطبيق على هذا الجهاز. يحتفظ الأعضاء الآخرون بنسختهم الخاصة.",
|
||||
"webxdcExternalLinkTitle": "مغادرة twonly؟",
|
||||
"webxdcExternalLinkBody": "يُفتح هذا الرابط خارج twonly، في متصفحك:"
|
||||
"webxdcExternalLinkBody": "يُفتح هذا الرابط خارج twonly، في متصفحك:",
|
||||
"notificationStory": "قصة جديدة.",
|
||||
"settingsNotifyStories": "إشعارات القصص",
|
||||
"settingsNotifyStoriesSubtitle": "تلقَّ إشعارًا بأول قصة في اليوم من جهات الاتصال التي راسلتها خلال الأسبوعين الماضيين.",
|
||||
"contactGroupShareStories": "مشاركة القصص",
|
||||
"contactGroupShareStoriesSubtitle": "اعرض هذه المجموعة كجمهور عند نشر قصة.",
|
||||
"contactGroupStoriesFeature": "القصص",
|
||||
"story": "قصة",
|
||||
"storyExpired": "انتهت صلاحية القصة",
|
||||
"storyMine": "قصتي",
|
||||
"storyDeleteTitle": "حذف القصة؟",
|
||||
"storyDeleteBody": "سيؤدي هذا إلى إزالتها من قصتك.",
|
||||
"storyDelete": "حذف",
|
||||
"storyDeleteFailed": "تعذّر الحذف. حاول مرة أخرى بعد قليل.",
|
||||
"shareImageStory": "قصة",
|
||||
"shareImageStoryAudience": "{count, plural, =1{تصل إلى جهة اتصال واحدة} other{تصل إلى {count} من جهات الاتصال}}",
|
||||
"@shareImageStoryAudience": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shareImageStoryUnavailable": "لا يمكن نشر الوسائط المحمية بـ twonly كقصة.",
|
||||
"shareImageStoryAll": "كل جهات الاتصال",
|
||||
"shareImageStoryNewGroup": "مجموعة قصص جديدة",
|
||||
"shareImageStoryFailed": "تعذّر نشر القصة. يمكن أن يصل الإرسال الواحد إلى {max} شخص كحد أقصى.",
|
||||
"@shareImageStoryFailed": {
|
||||
"placeholders": {
|
||||
"max": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notificationStoryReply": "رد على قصتك.",
|
||||
"notificationStoryReaction": "تفاعل {reaction} مع قصتك.",
|
||||
"notificationStoredStory": "تم حفظ قصتك.",
|
||||
"storyViews": "{count, plural, =0{لا مشاهدات بعد} =1{مشاهدة واحدة} other{{count} مشاهدات}}",
|
||||
"@storyViews": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1105,5 +1105,49 @@
|
|||
"webxdcUnavailable": "Diese App konnte nicht geladen werden.",
|
||||
"webxdcDeleteConfirm": "Alles, was diese App auf diesem Gerät gespeichert hat, wird gelöscht. Andere Mitglieder behalten ihre eigene Kopie.",
|
||||
"webxdcExternalLinkTitle": "twonly verlassen?",
|
||||
"webxdcExternalLinkBody": "Dieser Link öffnet sich außerhalb von twonly in deinem Browser:"
|
||||
"webxdcExternalLinkBody": "Dieser Link öffnet sich außerhalb von twonly in deinem Browser:",
|
||||
"notificationStory": "hat eine Story gepostet.",
|
||||
"settingsNotifyStories": "Story-Benachrichtigungen",
|
||||
"settingsNotifyStoriesSubtitle": "Werde über die erste Story des Tages von Kontakten benachrichtigt, mit denen du in den letzten zwei Wochen geschrieben hast.",
|
||||
"contactGroupShareStories": "Stories teilen",
|
||||
"contactGroupShareStoriesSubtitle": "Biete diese Gruppe als Publikum an, wenn du eine Story postest.",
|
||||
"contactGroupStoriesFeature": "Stories",
|
||||
"story": "Story",
|
||||
"storyExpired": "Story abgelaufen",
|
||||
"storyMine": "Meine Story",
|
||||
"storyDeleteTitle": "Story löschen?",
|
||||
"storyDeleteBody": "Dadurch wird sie aus deiner Story entfernt.",
|
||||
"storyDelete": "Löschen",
|
||||
"storyDeleteFailed": "Konnte nicht gelöscht werden. Versuche es gleich noch einmal.",
|
||||
"shareImageStory": "Story",
|
||||
"shareImageStoryAudience": "{count, plural, =1{Erreicht 1 Kontakt} other{Erreicht {count} Kontakte}}",
|
||||
"@shareImageStoryAudience": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shareImageStoryUnavailable": "Mit twonly geschützte Medien können nicht als Story gepostet werden.",
|
||||
"shareImageStoryAll": "Alle Kontakte",
|
||||
"shareImageStoryNewGroup": "Neue Story-Gruppe",
|
||||
"shareImageStoryFailed": "Die Story konnte nicht gepostet werden. Ein Versand erreicht höchstens {max} Personen.",
|
||||
"@shareImageStoryFailed": {
|
||||
"placeholders": {
|
||||
"max": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notificationStoryReply": "hat auf deine Story geantwortet.",
|
||||
"notificationStoryReaction": "hat mit {reaction} auf deine Story reagiert.",
|
||||
"notificationStoredStory": "hat deine Story gespeichert.",
|
||||
"storyViews": "{count, plural, =0{Noch keine Aufrufe} =1{1 Aufruf} other{{count} Aufrufe}}",
|
||||
"@storyViews": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1115,5 +1115,49 @@
|
|||
"webxdcUnavailable": "This app could not be loaded.",
|
||||
"webxdcDeleteConfirm": "Everything this app saved on this device is deleted. Other members keep their own copy.",
|
||||
"webxdcExternalLinkTitle": "Leave twonly?",
|
||||
"webxdcExternalLinkBody": "This link opens outside twonly, in your browser:"
|
||||
"webxdcExternalLinkBody": "This link opens outside twonly, in your browser:",
|
||||
"notificationStory": "posted a story.",
|
||||
"settingsNotifyStories": "Story notifications",
|
||||
"settingsNotifyStoriesSubtitle": "Be notified of the first story of the day from contacts you've written with in the last two weeks.",
|
||||
"contactGroupShareStories": "Share stories",
|
||||
"contactGroupShareStoriesSubtitle": "Offer this group as an audience when you post a story.",
|
||||
"contactGroupStoriesFeature": "Stories",
|
||||
"story": "Story",
|
||||
"storyExpired": "Story expired",
|
||||
"storyMine": "My story",
|
||||
"storyDeleteTitle": "Delete story?",
|
||||
"storyDeleteBody": "This removes it from your story.",
|
||||
"storyDelete": "Delete",
|
||||
"storyDeleteFailed": "Could not delete it. Try again in a moment.",
|
||||
"shareImageStory": "Story",
|
||||
"shareImageStoryAudience": "{count, plural, =1{Reaches 1 contact} other{Reaches {count} contacts}}",
|
||||
"@shareImageStoryAudience": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shareImageStoryUnavailable": "twonly-protected media can't be posted as a story.",
|
||||
"shareImageStoryAll": "All contacts",
|
||||
"shareImageStoryNewGroup": "New story group",
|
||||
"shareImageStoryFailed": "Could not post the story. One send can reach at most {max} people.",
|
||||
"@shareImageStoryFailed": {
|
||||
"placeholders": {
|
||||
"max": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notificationStoryReply": "has replied to your story.",
|
||||
"notificationStoryReaction": "has reacted with {reaction} to your story.",
|
||||
"notificationStoredStory": "has saved your story.",
|
||||
"storyViews": "{count, plural, =0{No views yet} =1{1 view} other{{count} views}}",
|
||||
"@storyViews": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -373,12 +373,15 @@ class EncryptedContent_TextMessage extends $pb.GeneratedMessage {
|
|||
$core.String? text,
|
||||
$fixnum.Int64? timestamp,
|
||||
$core.String? quoteMessageId,
|
||||
$core.List<$core.int>? additionalMessageData,
|
||||
}) {
|
||||
final result = create();
|
||||
if (senderMessageId != null) result.senderMessageId = senderMessageId;
|
||||
if (text != null) result.text = text;
|
||||
if (timestamp != null) result.timestamp = timestamp;
|
||||
if (quoteMessageId != null) result.quoteMessageId = quoteMessageId;
|
||||
if (additionalMessageData != null)
|
||||
result.additionalMessageData = additionalMessageData;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -398,6 +401,8 @@ class EncryptedContent_TextMessage extends $pb.GeneratedMessage {
|
|||
..aOS(2, _omitFieldNames ? '' : 'text')
|
||||
..aInt64(3, _omitFieldNames ? '' : 'timestamp')
|
||||
..aOS(4, _omitFieldNames ? '' : 'quoteMessageId')
|
||||
..a<$core.List<$core.int>>(
|
||||
5, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
|
|
@ -457,6 +462,18 @@ class EncryptedContent_TextMessage extends $pb.GeneratedMessage {
|
|||
$core.bool hasQuoteMessageId() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearQuoteMessageId() => $_clearField(4);
|
||||
|
||||
/// Carries where the text came from when it was not typed by the sender --
|
||||
/// today, the webxdc app that produced it.
|
||||
@$pb.TagNumber(5)
|
||||
$core.List<$core.int> get additionalMessageData => $_getN(4);
|
||||
@$pb.TagNumber(5)
|
||||
set additionalMessageData($core.List<$core.int> value) =>
|
||||
$_setBytes(4, value);
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool hasAdditionalMessageData() => $_has(4);
|
||||
@$pb.TagNumber(5)
|
||||
void clearAdditionalMessageData() => $_clearField(5);
|
||||
}
|
||||
|
||||
class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage {
|
||||
|
|
@ -465,6 +482,7 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage {
|
|||
$fixnum.Int64? timestamp,
|
||||
$core.String? type,
|
||||
$core.List<$core.int>? additionalMessageData,
|
||||
$core.bool? hidden,
|
||||
}) {
|
||||
final result = create();
|
||||
if (senderMessageId != null) result.senderMessageId = senderMessageId;
|
||||
|
|
@ -472,6 +490,7 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage {
|
|||
if (type != null) result.type = type;
|
||||
if (additionalMessageData != null)
|
||||
result.additionalMessageData = additionalMessageData;
|
||||
if (hidden != null) result.hidden = hidden;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -493,6 +512,7 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage {
|
|||
..aOS(3, _omitFieldNames ? '' : 'type')
|
||||
..a<$core.List<$core.int>>(
|
||||
4, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY)
|
||||
..aOB(5, _omitFieldNames ? '' : 'hidden')
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
|
|
@ -554,6 +574,22 @@ class EncryptedContent_AdditionalDataMessage extends $pb.GeneratedMessage {
|
|||
$core.bool hasAdditionalMessageData() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearAdditionalMessageData() => $_clearField(4);
|
||||
|
||||
/// State a feature exchanges rather than something a person sent. A hidden
|
||||
/// message leaves no row in `messages`, so it never appears in a chat, and
|
||||
/// it neither raises a notification nor wakes the receiver.
|
||||
///
|
||||
/// The one exception is a webxdc update carrying an `info`: the state stays
|
||||
/// hidden, but the announcement in it is written to be read and becomes a
|
||||
/// chat row of its own on arrival, which is worth waking for.
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool get hidden => $_getBF(4);
|
||||
@$pb.TagNumber(5)
|
||||
set hidden($core.bool value) => $_setBool(4, value);
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool hasHidden() => $_has(4);
|
||||
@$pb.TagNumber(5)
|
||||
void clearHidden() => $_clearField(5);
|
||||
}
|
||||
|
||||
class EncryptedContent_Reaction extends $pb.GeneratedMessage {
|
||||
|
|
@ -747,6 +783,7 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
|
|||
$core.List<$core.int>? encryptionMac,
|
||||
$core.List<$core.int>? encryptionNonce,
|
||||
$core.List<$core.int>? additionalMessageData,
|
||||
$core.bool? widgetOnly,
|
||||
}) {
|
||||
final result = create();
|
||||
if (senderMessageId != null) result.senderMessageId = senderMessageId;
|
||||
|
|
@ -763,6 +800,7 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
|
|||
if (encryptionNonce != null) result.encryptionNonce = encryptionNonce;
|
||||
if (additionalMessageData != null)
|
||||
result.additionalMessageData = additionalMessageData;
|
||||
if (widgetOnly != null) result.widgetOnly = widgetOnly;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -795,6 +833,7 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
|
|||
10, _omitFieldNames ? '' : 'encryptionNonce', $pb.PbFieldType.OY)
|
||||
..a<$core.List<$core.int>>(
|
||||
11, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY)
|
||||
..aOB(12, _omitFieldNames ? '' : 'widgetOnly')
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
|
|
@ -916,6 +955,90 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
|
|||
$core.bool hasAdditionalMessageData() => $_has(10);
|
||||
@$pb.TagNumber(11)
|
||||
void clearAdditionalMessageData() => $_clearField(11);
|
||||
|
||||
@$pb.TagNumber(12)
|
||||
$core.bool get widgetOnly => $_getBF(11);
|
||||
@$pb.TagNumber(12)
|
||||
set widgetOnly($core.bool value) => $_setBool(11, value);
|
||||
@$pb.TagNumber(12)
|
||||
$core.bool hasWidgetOnly() => $_has(11);
|
||||
@$pb.TagNumber(12)
|
||||
void clearWidgetOnly() => $_clearField(12);
|
||||
}
|
||||
|
||||
/// A story item. It carries no group ID: the receiver files it under its 1:1
|
||||
/// chat with the sender, keeps it for 24 hours after `media.timestamp`, and
|
||||
/// never shows it in that chat unless it is saved.
|
||||
class EncryptedContent_Story extends $pb.GeneratedMessage {
|
||||
factory EncryptedContent_Story({
|
||||
EncryptedContent_Media? media,
|
||||
$core.bool? notify,
|
||||
}) {
|
||||
final result = create();
|
||||
if (media != null) result.media = media;
|
||||
if (notify != null) result.notify = notify;
|
||||
return result;
|
||||
}
|
||||
|
||||
EncryptedContent_Story._();
|
||||
|
||||
factory EncryptedContent_Story.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory EncryptedContent_Story.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'EncryptedContent.Story',
|
||||
createEmptyInstance: create)
|
||||
..aOM<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 {
|
||||
|
|
@ -1649,6 +1772,8 @@ class EncryptedContent extends $pb.GeneratedMessage {
|
|||
EncryptedContent_PasswordLessRecovery? passwordlessRecovery,
|
||||
EncryptedContent_PasswordLessRecoveryHeartbeat?
|
||||
passwordlessRecoveryHeartbeat,
|
||||
$core.bool? widgetSharingAllowed,
|
||||
EncryptedContent_Story? story,
|
||||
}) {
|
||||
final result = create();
|
||||
if (groupId != null) result.groupId = groupId;
|
||||
|
|
@ -1686,6 +1811,9 @@ class EncryptedContent extends $pb.GeneratedMessage {
|
|||
result.passwordlessRecovery = passwordlessRecovery;
|
||||
if (passwordlessRecoveryHeartbeat != null)
|
||||
result.passwordlessRecoveryHeartbeat = passwordlessRecoveryHeartbeat;
|
||||
if (widgetSharingAllowed != null)
|
||||
result.widgetSharingAllowed = widgetSharingAllowed;
|
||||
if (story != null) result.story = story;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -1762,6 +1890,9 @@ class EncryptedContent extends $pb.GeneratedMessage {
|
|||
..aOM<EncryptedContent_PasswordLessRecoveryHeartbeat>(
|
||||
27, _omitFieldNames ? '' : 'passwordlessRecoveryHeartbeat',
|
||||
subBuilder: EncryptedContent_PasswordLessRecoveryHeartbeat.create)
|
||||
..aOB(29, _omitFieldNames ? '' : 'widgetSharingAllowed')
|
||||
..aOM<EncryptedContent_Story>(30, _omitFieldNames ? '' : 'story',
|
||||
subBuilder: EncryptedContent_Story.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
|
|
@ -2071,6 +2202,26 @@ class EncryptedContent extends $pb.GeneratedMessage {
|
|||
@$pb.TagNumber(27)
|
||||
EncryptedContent_PasswordLessRecoveryHeartbeat
|
||||
ensurePasswordlessRecoveryHeartbeat() => $_ensure(24);
|
||||
|
||||
@$pb.TagNumber(29)
|
||||
$core.bool get widgetSharingAllowed => $_getBF(25);
|
||||
@$pb.TagNumber(29)
|
||||
set widgetSharingAllowed($core.bool value) => $_setBool(25, value);
|
||||
@$pb.TagNumber(29)
|
||||
$core.bool hasWidgetSharingAllowed() => $_has(25);
|
||||
@$pb.TagNumber(29)
|
||||
void clearWidgetSharingAllowed() => $_clearField(29);
|
||||
|
||||
@$pb.TagNumber(30)
|
||||
EncryptedContent_Story get story => $_getN(26);
|
||||
@$pb.TagNumber(30)
|
||||
set story(EncryptedContent_Story value) => $_setField(30, value);
|
||||
@$pb.TagNumber(30)
|
||||
$core.bool hasStory() => $_has(26);
|
||||
@$pb.TagNumber(30)
|
||||
void clearStory() => $_clearField(30);
|
||||
@$pb.TagNumber(30)
|
||||
EncryptedContent_Story ensureStory() => $_ensure(26);
|
||||
}
|
||||
|
||||
const $core.bool _omitFieldNames =
|
||||
|
|
|
|||
|
|
@ -64,13 +64,22 @@ const EncryptedContent$json = {
|
|||
'10': 'askForFriendPromotions',
|
||||
'17': true
|
||||
},
|
||||
{
|
||||
'1': 'widget_sharing_allowed',
|
||||
'3': 29,
|
||||
'4': 1,
|
||||
'5': 8,
|
||||
'9': 5,
|
||||
'10': 'widgetSharingAllowed',
|
||||
'17': true
|
||||
},
|
||||
{
|
||||
'1': 'message_update',
|
||||
'3': 5,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.MessageUpdate',
|
||||
'9': 5,
|
||||
'9': 6,
|
||||
'10': 'messageUpdate',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -80,7 +89,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.Media',
|
||||
'9': 6,
|
||||
'9': 7,
|
||||
'10': 'media',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -90,7 +99,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.MediaUpdate',
|
||||
'9': 7,
|
||||
'9': 8,
|
||||
'10': 'mediaUpdate',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -100,7 +109,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.ContactUpdate',
|
||||
'9': 8,
|
||||
'9': 9,
|
||||
'10': 'contactUpdate',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -110,7 +119,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.ContactRequest',
|
||||
'9': 9,
|
||||
'9': 10,
|
||||
'10': 'contactRequest',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -120,7 +129,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.FlameSync',
|
||||
'9': 10,
|
||||
'9': 11,
|
||||
'10': 'flameSync',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -130,7 +139,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.Reaction',
|
||||
'9': 11,
|
||||
'9': 12,
|
||||
'10': 'reaction',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -140,7 +149,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.TextMessage',
|
||||
'9': 12,
|
||||
'9': 13,
|
||||
'10': 'textMessage',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -150,7 +159,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.GroupCreate',
|
||||
'9': 13,
|
||||
'9': 14,
|
||||
'10': 'groupCreate',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -160,7 +169,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.GroupJoin',
|
||||
'9': 14,
|
||||
'9': 15,
|
||||
'10': 'groupJoin',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -170,7 +179,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.GroupUpdate',
|
||||
'9': 15,
|
||||
'9': 16,
|
||||
'10': 'groupUpdate',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -180,7 +189,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.ResendGroupPublicKey',
|
||||
'9': 16,
|
||||
'9': 17,
|
||||
'10': 'resendGroupPublicKey',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -190,7 +199,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.ErrorMessages',
|
||||
'9': 17,
|
||||
'9': 18,
|
||||
'10': 'errorMessages',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -200,7 +209,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.AdditionalDataMessage',
|
||||
'9': 18,
|
||||
'9': 19,
|
||||
'10': 'additionalDataMessage',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -210,7 +219,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.TypingIndicator',
|
||||
'9': 19,
|
||||
'9': 20,
|
||||
'10': 'typingIndicator',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -220,7 +229,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.UserDiscoveryRequest',
|
||||
'9': 20,
|
||||
'9': 21,
|
||||
'10': 'userDiscoveryRequest',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -230,7 +239,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.UserDiscoveryUpdate',
|
||||
'9': 21,
|
||||
'9': 22,
|
||||
'10': 'userDiscoveryUpdate',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -240,7 +249,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.KeyVerificationProof',
|
||||
'9': 22,
|
||||
'9': 23,
|
||||
'10': 'keyVerificationProof',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -250,7 +259,7 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.PasswordLessRecovery',
|
||||
'9': 23,
|
||||
'9': 24,
|
||||
'10': 'passwordlessRecovery',
|
||||
'17': true
|
||||
},
|
||||
|
|
@ -260,10 +269,20 @@ const EncryptedContent$json = {
|
|||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.PasswordLessRecoveryHeartbeat',
|
||||
'9': 24,
|
||||
'9': 25,
|
||||
'10': 'passwordlessRecoveryHeartbeat',
|
||||
'17': true
|
||||
},
|
||||
{
|
||||
'1': 'story',
|
||||
'3': 30,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.Story',
|
||||
'9': 26,
|
||||
'10': 'story',
|
||||
'17': true
|
||||
},
|
||||
],
|
||||
'3': [
|
||||
EncryptedContent_ErrorMessages$json,
|
||||
|
|
@ -276,6 +295,7 @@ const EncryptedContent$json = {
|
|||
EncryptedContent_Reaction$json,
|
||||
EncryptedContent_MessageUpdate$json,
|
||||
EncryptedContent_Media$json,
|
||||
EncryptedContent_Story$json,
|
||||
EncryptedContent_MediaUpdate$json,
|
||||
EncryptedContent_ContactRequest$json,
|
||||
EncryptedContent_ContactUpdate$json,
|
||||
|
|
@ -293,6 +313,7 @@ const EncryptedContent$json = {
|
|||
{'1': '_sender_profile_counter'},
|
||||
{'1': '_sender_user_discovery_version'},
|
||||
{'1': '_ask_for_friend_promotions'},
|
||||
{'1': '_widget_sharing_allowed'},
|
||||
{'1': '_message_update'},
|
||||
{'1': '_media'},
|
||||
{'1': '_media_update'},
|
||||
|
|
@ -313,6 +334,10 @@ const EncryptedContent$json = {
|
|||
{'1': '_key_verification_proof'},
|
||||
{'1': '_passwordless_recovery'},
|
||||
{'1': '_passwordless_recovery_heartbeat'},
|
||||
{'1': '_story'},
|
||||
],
|
||||
'9': [
|
||||
{'1': 28, '2': 29},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -440,9 +465,19 @@ const EncryptedContent_TextMessage$json = {
|
|||
'10': 'quoteMessageId',
|
||||
'17': true
|
||||
},
|
||||
{
|
||||
'1': 'additional_message_data',
|
||||
'3': 5,
|
||||
'4': 1,
|
||||
'5': 12,
|
||||
'9': 1,
|
||||
'10': 'additionalMessageData',
|
||||
'17': true
|
||||
},
|
||||
],
|
||||
'8': [
|
||||
{'1': '_quote_message_id'},
|
||||
{'1': '_additional_message_data'},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -462,6 +497,7 @@ const EncryptedContent_AdditionalDataMessage$json = {
|
|||
'10': 'additionalMessageData',
|
||||
'17': true
|
||||
},
|
||||
{'1': 'hidden', '3': 5, '4': 1, '5': 8, '10': 'hidden'},
|
||||
],
|
||||
'8': [
|
||||
{'1': '_additional_message_data'},
|
||||
|
|
@ -610,6 +646,15 @@ const EncryptedContent_Media$json = {
|
|||
'10': 'additionalMessageData',
|
||||
'17': true
|
||||
},
|
||||
{
|
||||
'1': 'widget_only',
|
||||
'3': 12,
|
||||
'4': 1,
|
||||
'5': 8,
|
||||
'9': 7,
|
||||
'10': 'widgetOnly',
|
||||
'17': true
|
||||
},
|
||||
],
|
||||
'4': [EncryptedContent_Media_Type$json],
|
||||
'8': [
|
||||
|
|
@ -620,6 +665,7 @@ const EncryptedContent_Media$json = {
|
|||
{'1': '_encryption_mac'},
|
||||
{'1': '_encryption_nonce'},
|
||||
{'1': '_additional_message_data'},
|
||||
{'1': '_widget_only'},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -635,6 +681,22 @@ const EncryptedContent_Media_Type$json = {
|
|||
],
|
||||
};
|
||||
|
||||
@$core.Deprecated('Use encryptedContentDescriptor instead')
|
||||
const EncryptedContent_Story$json = {
|
||||
'1': 'Story',
|
||||
'2': [
|
||||
{
|
||||
'1': 'media',
|
||||
'3': 1,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.EncryptedContent.Media',
|
||||
'10': 'media'
|
||||
},
|
||||
{'1': 'notify', '3': 2, '4': 1, '5': 8, '10': 'notify'},
|
||||
],
|
||||
};
|
||||
|
||||
@$core.Deprecated('Use encryptedContentDescriptor instead')
|
||||
const EncryptedContent_MediaUpdate$json = {
|
||||
'1': 'MediaUpdate',
|
||||
|
|
@ -831,36 +893,38 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
|
|||
'X2NvdW50ZXIYBCABKANIAlIUc2VuZGVyUHJvZmlsZUNvdW50ZXKIAQESRgodc2VuZGVyX3VzZX'
|
||||
'JfZGlzY292ZXJ5X3ZlcnNpb24YFSABKAxIA1Iac2VuZGVyVXNlckRpc2NvdmVyeVZlcnNpb26I'
|
||||
'AQESPgoZYXNrX2Zvcl9mcmllbmRfcHJvbW90aW9ucxgZIAEoCEgEUhZhc2tGb3JGcmllbmRQcm'
|
||||
'9tb3Rpb25ziAEBEksKDm1lc3NhZ2VfdXBkYXRlGAUgASgLMh8uRW5jcnlwdGVkQ29udGVudC5N'
|
||||
'ZXNzYWdlVXBkYXRlSAVSDW1lc3NhZ2VVcGRhdGWIAQESMgoFbWVkaWEYBiABKAsyFy5FbmNyeX'
|
||||
'B0ZWRDb250ZW50Lk1lZGlhSAZSBW1lZGlhiAEBEkUKDG1lZGlhX3VwZGF0ZRgHIAEoCzIdLkVu'
|
||||
'Y3J5cHRlZENvbnRlbnQuTWVkaWFVcGRhdGVIB1ILbWVkaWFVcGRhdGWIAQESSwoOY29udGFjdF'
|
||||
'91cGRhdGUYCCABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGVICFINY29udGFj'
|
||||
'dFVwZGF0ZYgBARJOCg9jb250YWN0X3JlcXVlc3QYCSABKAsyIC5FbmNyeXB0ZWRDb250ZW50Lk'
|
||||
'NvbnRhY3RSZXF1ZXN0SAlSDmNvbnRhY3RSZXF1ZXN0iAEBEj8KCmZsYW1lX3N5bmMYCiABKAsy'
|
||||
'Gy5FbmNyeXB0ZWRDb250ZW50LkZsYW1lU3luY0gKUglmbGFtZVN5bmOIAQESOwoIcmVhY3Rpb2'
|
||||
'4YDCABKAsyGi5FbmNyeXB0ZWRDb250ZW50LlJlYWN0aW9uSAtSCHJlYWN0aW9uiAEBEkUKDHRl'
|
||||
'eHRfbWVzc2FnZRgNIAEoCzIdLkVuY3J5cHRlZENvbnRlbnQuVGV4dE1lc3NhZ2VIDFILdGV4dE'
|
||||
'1lc3NhZ2WIAQESRQoMZ3JvdXBfY3JlYXRlGA4gASgLMh0uRW5jcnlwdGVkQ29udGVudC5Hcm91'
|
||||
'cENyZWF0ZUgNUgtncm91cENyZWF0ZYgBARI/Cgpncm91cF9qb2luGA8gASgLMhsuRW5jcnlwdG'
|
||||
'VkQ29udGVudC5Hcm91cEpvaW5IDlIJZ3JvdXBKb2luiAEBEkUKDGdyb3VwX3VwZGF0ZRgQIAEo'
|
||||
'CzIdLkVuY3J5cHRlZENvbnRlbnQuR3JvdXBVcGRhdGVID1ILZ3JvdXBVcGRhdGWIAQESYgoXcm'
|
||||
'VzZW5kX2dyb3VwX3B1YmxpY19rZXkYESABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlJlc2VuZEdy'
|
||||
'b3VwUHVibGljS2V5SBBSFHJlc2VuZEdyb3VwUHVibGljS2V5iAEBEksKDmVycm9yX21lc3NhZ2'
|
||||
'VzGBIgASgLMh8uRW5jcnlwdGVkQ29udGVudC5FcnJvck1lc3NhZ2VzSBFSDWVycm9yTWVzc2Fn'
|
||||
'ZXOIAQESZAoXYWRkaXRpb25hbF9kYXRhX21lc3NhZ2UYEyABKAsyJy5FbmNyeXB0ZWRDb250ZW'
|
||||
'50LkFkZGl0aW9uYWxEYXRhTWVzc2FnZUgSUhVhZGRpdGlvbmFsRGF0YU1lc3NhZ2WIAQESUQoQ'
|
||||
'dHlwaW5nX2luZGljYXRvchgUIAEoCzIhLkVuY3J5cHRlZENvbnRlbnQuVHlwaW5nSW5kaWNhdG'
|
||||
'9ySBNSD3R5cGluZ0luZGljYXRvcogBARJhChZ1c2VyX2Rpc2NvdmVyeV9yZXF1ZXN0GBYgASgL'
|
||||
'MiYuRW5jcnlwdGVkQ29udGVudC5Vc2VyRGlzY292ZXJ5UmVxdWVzdEgUUhR1c2VyRGlzY292ZX'
|
||||
'J5UmVxdWVzdIgBARJeChV1c2VyX2Rpc2NvdmVyeV91cGRhdGUYFyABKAsyJS5FbmNyeXB0ZWRD'
|
||||
'b250ZW50LlVzZXJEaXNjb3ZlcnlVcGRhdGVIFVITdXNlckRpc2NvdmVyeVVwZGF0ZYgBARJhCh'
|
||||
'ZrZXlfdmVyaWZpY2F0aW9uX3Byb29mGBggASgLMiYuRW5jcnlwdGVkQ29udGVudC5LZXlWZXJp'
|
||||
'ZmljYXRpb25Qcm9vZkgWUhRrZXlWZXJpZmljYXRpb25Qcm9vZogBARJgChVwYXNzd29yZGxlc3'
|
||||
'NfcmVjb3ZlcnkYGiABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlBhc3N3b3JkTGVzc1JlY292ZXJ5'
|
||||
'SBdSFHBhc3N3b3JkbGVzc1JlY292ZXJ5iAEBEnwKH3Bhc3N3b3JkbGVzc19yZWNvdmVyeV9oZW'
|
||||
'FydGJlYXQYGyABKAsyLy5FbmNyeXB0ZWRDb250ZW50LlBhc3N3b3JkTGVzc1JlY292ZXJ5SGVh'
|
||||
'cnRiZWF0SBhSHXBhc3N3b3JkbGVzc1JlY292ZXJ5SGVhcnRiZWF0iAEBGpYCCg1FcnJvck1lc3'
|
||||
'9tb3Rpb25ziAEBEjkKFndpZGdldF9zaGFyaW5nX2FsbG93ZWQYHSABKAhIBVIUd2lkZ2V0U2hh'
|
||||
'cmluZ0FsbG93ZWSIAQESSwoObWVzc2FnZV91cGRhdGUYBSABKAsyHy5FbmNyeXB0ZWRDb250ZW'
|
||||
'50Lk1lc3NhZ2VVcGRhdGVIBlINbWVzc2FnZVVwZGF0ZYgBARIyCgVtZWRpYRgGIAEoCzIXLkVu'
|
||||
'Y3J5cHRlZENvbnRlbnQuTWVkaWFIB1IFbWVkaWGIAQESRQoMbWVkaWFfdXBkYXRlGAcgASgLMh'
|
||||
'0uRW5jcnlwdGVkQ29udGVudC5NZWRpYVVwZGF0ZUgIUgttZWRpYVVwZGF0ZYgBARJLCg5jb250'
|
||||
'YWN0X3VwZGF0ZRgIIAEoCzIfLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFVwZGF0ZUgJUg1jb2'
|
||||
'50YWN0VXBkYXRliAEBEk4KD2NvbnRhY3RfcmVxdWVzdBgJIAEoCzIgLkVuY3J5cHRlZENvbnRl'
|
||||
'bnQuQ29udGFjdFJlcXVlc3RIClIOY29udGFjdFJlcXVlc3SIAQESPwoKZmxhbWVfc3luYxgKIA'
|
||||
'EoCzIbLkVuY3J5cHRlZENvbnRlbnQuRmxhbWVTeW5jSAtSCWZsYW1lU3luY4gBARI7CghyZWFj'
|
||||
'dGlvbhgMIAEoCzIaLkVuY3J5cHRlZENvbnRlbnQuUmVhY3Rpb25IDFIIcmVhY3Rpb26IAQESRQ'
|
||||
'oMdGV4dF9tZXNzYWdlGA0gASgLMh0uRW5jcnlwdGVkQ29udGVudC5UZXh0TWVzc2FnZUgNUgt0'
|
||||
'ZXh0TWVzc2FnZYgBARJFCgxncm91cF9jcmVhdGUYDiABKAsyHS5FbmNyeXB0ZWRDb250ZW50Lk'
|
||||
'dyb3VwQ3JlYXRlSA5SC2dyb3VwQ3JlYXRliAEBEj8KCmdyb3VwX2pvaW4YDyABKAsyGy5FbmNy'
|
||||
'eXB0ZWRDb250ZW50Lkdyb3VwSm9pbkgPUglncm91cEpvaW6IAQESRQoMZ3JvdXBfdXBkYXRlGB'
|
||||
'AgASgLMh0uRW5jcnlwdGVkQ29udGVudC5Hcm91cFVwZGF0ZUgQUgtncm91cFVwZGF0ZYgBARJi'
|
||||
'ChdyZXNlbmRfZ3JvdXBfcHVibGljX2tleRgRIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuUmVzZW'
|
||||
'5kR3JvdXBQdWJsaWNLZXlIEVIUcmVzZW5kR3JvdXBQdWJsaWNLZXmIAQESSwoOZXJyb3JfbWVz'
|
||||
'c2FnZXMYEiABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXNIElINZXJyb3JNZX'
|
||||
'NzYWdlc4gBARJkChdhZGRpdGlvbmFsX2RhdGFfbWVzc2FnZRgTIAEoCzInLkVuY3J5cHRlZENv'
|
||||
'bnRlbnQuQWRkaXRpb25hbERhdGFNZXNzYWdlSBNSFWFkZGl0aW9uYWxEYXRhTWVzc2FnZYgBAR'
|
||||
'JRChB0eXBpbmdfaW5kaWNhdG9yGBQgASgLMiEuRW5jcnlwdGVkQ29udGVudC5UeXBpbmdJbmRp'
|
||||
'Y2F0b3JIFFIPdHlwaW5nSW5kaWNhdG9yiAEBEmEKFnVzZXJfZGlzY292ZXJ5X3JlcXVlc3QYFi'
|
||||
'ABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlVzZXJEaXNjb3ZlcnlSZXF1ZXN0SBVSFHVzZXJEaXNj'
|
||||
'b3ZlcnlSZXF1ZXN0iAEBEl4KFXVzZXJfZGlzY292ZXJ5X3VwZGF0ZRgXIAEoCzIlLkVuY3J5cH'
|
||||
'RlZENvbnRlbnQuVXNlckRpc2NvdmVyeVVwZGF0ZUgWUhN1c2VyRGlzY292ZXJ5VXBkYXRliAEB'
|
||||
'EmEKFmtleV92ZXJpZmljYXRpb25fcHJvb2YYGCABKAsyJi5FbmNyeXB0ZWRDb250ZW50LktleV'
|
||||
'ZlcmlmaWNhdGlvblByb29mSBdSFGtleVZlcmlmaWNhdGlvblByb29miAEBEmAKFXBhc3N3b3Jk'
|
||||
'bGVzc19yZWNvdmVyeRgaIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuUGFzc3dvcmRMZXNzUmVjb3'
|
||||
'ZlcnlIGFIUcGFzc3dvcmRsZXNzUmVjb3ZlcnmIAQESfAofcGFzc3dvcmRsZXNzX3JlY292ZXJ5'
|
||||
'X2hlYXJ0YmVhdBgbIAEoCzIvLkVuY3J5cHRlZENvbnRlbnQuUGFzc3dvcmRMZXNzUmVjb3Zlcn'
|
||||
'lIZWFydGJlYXRIGVIdcGFzc3dvcmRsZXNzUmVjb3ZlcnlIZWFydGJlYXSIAQESMgoFc3RvcnkY'
|
||||
'HiABKAsyFy5FbmNyeXB0ZWRDb250ZW50LlN0b3J5SBpSBXN0b3J5iAEBGpYCCg1FcnJvck1lc3'
|
||||
'NhZ2VzEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXMuVHlw'
|
||||
'ZVIEdHlwZRIsChJyZWxhdGVkX3JlY2VpcHRfaWQYAiABKAlSEHJlbGF0ZWRSZWNlaXB0SWQinA'
|
||||
'EKBFR5cGUSPAo4RVJST1JfUFJPQ0VTU0lOR19NRVNTQUdFX0NSRUFURURfQUNDT1VOVF9SRVFV'
|
||||
|
|
@ -875,65 +939,70 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
|
|||
'X25hbWUYAyABKAlIAVIMbmV3R3JvdXBOYW1liAEBElcKJm5ld19kZWxldGVfbWVzc2FnZXNfYW'
|
||||
'Z0ZXJfbWlsbGlzZWNvbmRzGAQgASgDSAJSIm5ld0RlbGV0ZU1lc3NhZ2VzQWZ0ZXJNaWxsaXNl'
|
||||
'Y29uZHOIAQFCFgoUX2FmZmVjdGVkX2NvbnRhY3RfaWRCEQoPX25ld19ncm91cF9uYW1lQikKJ1'
|
||||
'9uZXdfZGVsZXRlX21lc3NhZ2VzX2FmdGVyX21pbGxpc2Vjb25kcxqvAQoLVGV4dE1lc3NhZ2US'
|
||||
'9uZXdfZGVsZXRlX21lc3NhZ2VzX2FmdGVyX21pbGxpc2Vjb25kcxqIAgoLVGV4dE1lc3NhZ2US'
|
||||
'KgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3NlbmRlck1lc3NhZ2VJZBISCgR0ZXh0GAIgAS'
|
||||
'gJUgR0ZXh0EhwKCXRpbWVzdGFtcBgDIAEoA1IJdGltZXN0YW1wEi0KEHF1b3RlX21lc3NhZ2Vf'
|
||||
'aWQYBCABKAlIAFIOcXVvdGVNZXNzYWdlSWSIAQFCEwoRX3F1b3RlX21lc3NhZ2VfaWQazgEKFU'
|
||||
'FkZGl0aW9uYWxEYXRhTWVzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2VuZGVy'
|
||||
'TWVzc2FnZUlkEhwKCXRpbWVzdGFtcBgCIAEoA1IJdGltZXN0YW1wEhIKBHR5cGUYAyABKAlSBH'
|
||||
'R5cGUSOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYBCABKAxIAFIVYWRkaXRpb25hbE1lc3Nh'
|
||||
'Z2VEYXRhiAEBQhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRpkCghSZWFjdGlvbhIqChF0YX'
|
||||
'JnZXRfbWVzc2FnZV9pZBgBIAEoCVIPdGFyZ2V0TWVzc2FnZUlkEhQKBWVtb2ppGAIgASgJUgVl'
|
||||
'bW9qaRIWCgZyZW1vdmUYAyABKAhSBnJlbW92ZRq+AgoNTWVzc2FnZVVwZGF0ZRI4CgR0eXBlGA'
|
||||
'EgASgOMiQuRW5jcnlwdGVkQ29udGVudC5NZXNzYWdlVXBkYXRlLlR5cGVSBHR5cGUSLwoRc2Vu'
|
||||
'ZGVyX21lc3NhZ2VfaWQYAiABKAlIAFIPc2VuZGVyTWVzc2FnZUlkiAEBEj0KG211bHRpcGxlX3'
|
||||
'RhcmdldF9tZXNzYWdlX2lkcxgDIAMoCVIYbXVsdGlwbGVUYXJnZXRNZXNzYWdlSWRzEhcKBHRl'
|
||||
'eHQYBCABKAlIAVIEdGV4dIgBARIcCgl0aW1lc3RhbXAYBSABKANSCXRpbWVzdGFtcCItCgRUeX'
|
||||
'BlEgoKBkRFTEVURRAAEg0KCUVESVRfVEVYVBABEgoKBk9QRU5FRBACQhQKEl9zZW5kZXJfbWVz'
|
||||
'c2FnZV9pZEIHCgVfdGV4dBqFBgoFTWVkaWESKgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3'
|
||||
'NlbmRlck1lc3NhZ2VJZBIwCgR0eXBlGAIgASgOMhwuRW5jcnlwdGVkQ29udGVudC5NZWRpYS5U'
|
||||
'eXBlUgR0eXBlEkYKHWRpc3BsYXlfbGltaXRfaW5fbWlsbGlzZWNvbmRzGAMgASgDSABSGmRpc3'
|
||||
'BsYXlMaW1pdEluTWlsbGlzZWNvbmRziAEBEjcKF3JlcXVpcmVzX2F1dGhlbnRpY2F0aW9uGAQg'
|
||||
'ASgIUhZyZXF1aXJlc0F1dGhlbnRpY2F0aW9uEhwKCXRpbWVzdGFtcBgFIAEoA1IJdGltZXN0YW'
|
||||
'1wEi0KEHF1b3RlX21lc3NhZ2VfaWQYBiABKAlIAVIOcXVvdGVNZXNzYWdlSWSIAQESKgoOZG93'
|
||||
'bmxvYWRfdG9rZW4YByABKAxIAlINZG93bmxvYWRUb2tlbogBARIqCg5lbmNyeXB0aW9uX2tleR'
|
||||
'gIIAEoDEgDUg1lbmNyeXB0aW9uS2V5iAEBEioKDmVuY3J5cHRpb25fbWFjGAkgASgMSARSDWVu'
|
||||
'Y3J5cHRpb25NYWOIAQESLgoQZW5jcnlwdGlvbl9ub25jZRgKIAEoDEgFUg9lbmNyeXB0aW9uTm'
|
||||
'9uY2WIAQESOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYCyABKAxIBlIVYWRkaXRpb25hbE1l'
|
||||
'c3NhZ2VEYXRhiAEBIj4KBFR5cGUSDAoIUkVVUExPQUQQABIJCgVJTUFHRRABEgkKBVZJREVPEA'
|
||||
'ISBwoDR0lGEAMSCQoFQVVESU8QBEIgCh5fZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY29uZHNC'
|
||||
'EwoRX3F1b3RlX21lc3NhZ2VfaWRCEQoPX2Rvd25sb2FkX3Rva2VuQhEKD19lbmNyeXB0aW9uX2'
|
||||
'tleUIRCg9fZW5jcnlwdGlvbl9tYWNCEwoRX2VuY3J5cHRpb25fbm9uY2VCGgoYX2FkZGl0aW9u'
|
||||
'YWxfbWVzc2FnZV9kYXRhGqkBCgtNZWRpYVVwZGF0ZRI2CgR0eXBlGAEgASgOMiIuRW5jcnlwdG'
|
||||
'VkQ29udGVudC5NZWRpYVVwZGF0ZS5UeXBlUgR0eXBlEioKEXRhcmdldF9tZXNzYWdlX2lkGAIg'
|
||||
'ASgJUg90YXJnZXRNZXNzYWdlSWQiNgoEVHlwZRIMCghSRU9QRU5FRBAAEgoKBlNUT1JFRBABEh'
|
||||
'QKEERFQ1JZUFRJT05fRVJST1IQAhp4Cg5Db250YWN0UmVxdWVzdBI5CgR0eXBlGAEgASgOMiUu'
|
||||
'RW5jcnlwdGVkQ29udGVudC5Db250YWN0UmVxdWVzdC5UeXBlUgR0eXBlIisKBFR5cGUSCwoHUk'
|
||||
'VRVUVTVBAAEgoKBlJFSkVDVBABEgoKBkFDQ0VQVBACGqQCCg1Db250YWN0VXBkYXRlEjgKBHR5'
|
||||
'cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGUuVHlwZVIEdHlwZRI3Ch'
|
||||
'VhdmF0YXJfc3ZnX2NvbXByZXNzZWQYAiABKAxIAFITYXZhdGFyU3ZnQ29tcHJlc3NlZIgBARIf'
|
||||
'Cgh1c2VybmFtZRgDIAEoCUgBUgh1c2VybmFtZYgBARImCgxkaXNwbGF5X25hbWUYBCABKAlIAl'
|
||||
'ILZGlzcGxheU5hbWWIAQEiHwoEVHlwZRILCgdSRVFVRVNUEAASCgoGVVBEQVRFEAFCGAoWX2F2'
|
||||
'YXRhcl9zdmdfY29tcHJlc3NlZEILCglfdXNlcm5hbWVCDwoNX2Rpc3BsYXlfbmFtZRqvAQoJRm'
|
||||
'xhbWVTeW5jEiMKDWZsYW1lX2NvdW50ZXIYASABKANSDGZsYW1lQ291bnRlchI5ChlsYXN0X2Zs'
|
||||
'YW1lX2NvdW50ZXJfY2hhbmdlGAIgASgDUhZsYXN0RmxhbWVDb3VudGVyQ2hhbmdlEh8KC2Jlc3'
|
||||
'RfZnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiEKDGZvcmNlX3VwZGF0ZRgEIAEoCFILZm9yY2VV'
|
||||
'cGRhdGUaTQoPVHlwaW5nSW5kaWNhdG9yEhsKCWlzX3R5cGluZxgBIAEoCFIIaXNUeXBpbmcSHQ'
|
||||
'oKY3JlYXRlZF9hdBgCIAEoA1IJY3JlYXRlZEF0Gj8KFFVzZXJEaXNjb3ZlcnlSZXF1ZXN0EicK'
|
||||
'D2N1cnJlbnRfdmVyc2lvbhgBIAEoDFIOY3VycmVudFZlcnNpb24aMQoTVXNlckRpc2NvdmVyeV'
|
||||
'VwZGF0ZRIaCghtZXNzYWdlcxgBIAMoDFIIbWVzc2FnZXMaPQoUS2V5VmVyaWZpY2F0aW9uUHJv'
|
||||
'b2YSJQoOY2FsY3VsYXRlZF9tYWMYASABKAxSDWNhbGN1bGF0ZWRNYWMamwEKFFBhc3N3b3JkTG'
|
||||
'Vzc1JlY292ZXJ5EjUKE3JlY292ZXJ5U2VjcmV0U2hhcmUYASABKAxIAFITcmVjb3ZlcnlTZWNy'
|
||||
'ZXRTaGFyZYgBARIWCgZkZWxldGUYAiABKAhSBmRlbGV0ZRIcCgl0aHJlc2hvbGQYAyABKANSCX'
|
||||
'RocmVzaG9sZEIWChRfcmVjb3ZlcnlTZWNyZXRTaGFyZRozCh1QYXNzd29yZExlc3NSZWNvdmVy'
|
||||
'eUhlYXJ0YmVhdBISCgRoYXNoGAEgASgMUgRoYXNoQgsKCV9ncm91cF9pZEIRCg9faXNfZGlyZW'
|
||||
'N0X2NoYXRCGQoXX3NlbmRlcl9wcm9maWxlX2NvdW50ZXJCIAoeX3NlbmRlcl91c2VyX2Rpc2Nv'
|
||||
'dmVyeV92ZXJzaW9uQhwKGl9hc2tfZm9yX2ZyaWVuZF9wcm9tb3Rpb25zQhEKD19tZXNzYWdlX3'
|
||||
'VwZGF0ZUIICgZfbWVkaWFCDwoNX21lZGlhX3VwZGF0ZUIRCg9fY29udGFjdF91cGRhdGVCEgoQ'
|
||||
'X2NvbnRhY3RfcmVxdWVzdEINCgtfZmxhbWVfc3luY0ILCglfcmVhY3Rpb25CDwoNX3RleHRfbW'
|
||||
'Vzc2FnZUIPCg1fZ3JvdXBfY3JlYXRlQg0KC19ncm91cF9qb2luQg8KDV9ncm91cF91cGRhdGVC'
|
||||
'GgoYX3Jlc2VuZF9ncm91cF9wdWJsaWNfa2V5QhEKD19lcnJvcl9tZXNzYWdlc0IaChhfYWRkaX'
|
||||
'Rpb25hbF9kYXRhX21lc3NhZ2VCEwoRX3R5cGluZ19pbmRpY2F0b3JCGQoXX3VzZXJfZGlzY292'
|
||||
'ZXJ5X3JlcXVlc3RCGAoWX3VzZXJfZGlzY292ZXJ5X3VwZGF0ZUIZChdfa2V5X3ZlcmlmaWNhdG'
|
||||
'lvbl9wcm9vZkIYChZfcGFzc3dvcmRsZXNzX3JlY292ZXJ5QiIKIF9wYXNzd29yZGxlc3NfcmVj'
|
||||
'b3ZlcnlfaGVhcnRiZWF0');
|
||||
'aWQYBCABKAlIAFIOcXVvdGVNZXNzYWdlSWSIAQESOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdG'
|
||||
'EYBSABKAxIAVIVYWRkaXRpb25hbE1lc3NhZ2VEYXRhiAEBQhMKEV9xdW90ZV9tZXNzYWdlX2lk'
|
||||
'QhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRrmAQoVQWRkaXRpb25hbERhdGFNZXNzYWdlEi'
|
||||
'oKEXNlbmRlcl9tZXNzYWdlX2lkGAEgASgJUg9zZW5kZXJNZXNzYWdlSWQSHAoJdGltZXN0YW1w'
|
||||
'GAIgASgDUgl0aW1lc3RhbXASEgoEdHlwZRgDIAEoCVIEdHlwZRI7ChdhZGRpdGlvbmFsX21lc3'
|
||||
'NhZ2VfZGF0YRgEIAEoDEgAUhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQESFgoGaGlkZGVuGAUg'
|
||||
'ASgIUgZoaWRkZW5CGgoYX2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGmQKCFJlYWN0aW9uEioKEX'
|
||||
'RhcmdldF9tZXNzYWdlX2lkGAEgASgJUg90YXJnZXRNZXNzYWdlSWQSFAoFZW1vamkYAiABKAlS'
|
||||
'BWVtb2ppEhYKBnJlbW92ZRgDIAEoCFIGcmVtb3ZlGr4CCg1NZXNzYWdlVXBkYXRlEjgKBHR5cG'
|
||||
'UYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50Lk1lc3NhZ2VVcGRhdGUuVHlwZVIEdHlwZRIvChFz'
|
||||
'ZW5kZXJfbWVzc2FnZV9pZBgCIAEoCUgAUg9zZW5kZXJNZXNzYWdlSWSIAQESPQobbXVsdGlwbG'
|
||||
'VfdGFyZ2V0X21lc3NhZ2VfaWRzGAMgAygJUhhtdWx0aXBsZVRhcmdldE1lc3NhZ2VJZHMSFwoE'
|
||||
'dGV4dBgEIAEoCUgBUgR0ZXh0iAEBEhwKCXRpbWVzdGFtcBgFIAEoA1IJdGltZXN0YW1wIi0KBF'
|
||||
'R5cGUSCgoGREVMRVRFEAASDQoJRURJVF9URVhUEAESCgoGT1BFTkVEEAJCFAoSX3NlbmRlcl9t'
|
||||
'ZXNzYWdlX2lkQgcKBV90ZXh0GrsGCgVNZWRpYRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCV'
|
||||
'IPc2VuZGVyTWVzc2FnZUlkEjAKBHR5cGUYAiABKA4yHC5FbmNyeXB0ZWRDb250ZW50Lk1lZGlh'
|
||||
'LlR5cGVSBHR5cGUSRgodZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY29uZHMYAyABKANIAFIaZG'
|
||||
'lzcGxheUxpbWl0SW5NaWxsaXNlY29uZHOIAQESNwoXcmVxdWlyZXNfYXV0aGVudGljYXRpb24Y'
|
||||
'BCABKAhSFnJlcXVpcmVzQXV0aGVudGljYXRpb24SHAoJdGltZXN0YW1wGAUgASgDUgl0aW1lc3'
|
||||
'RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZBgGIAEoCUgBUg5xdW90ZU1lc3NhZ2VJZIgBARIqCg5k'
|
||||
'b3dubG9hZF90b2tlbhgHIAEoDEgCUg1kb3dubG9hZFRva2VuiAEBEioKDmVuY3J5cHRpb25fa2'
|
||||
'V5GAggASgMSANSDWVuY3J5cHRpb25LZXmIAQESKgoOZW5jcnlwdGlvbl9tYWMYCSABKAxIBFIN'
|
||||
'ZW5jcnlwdGlvbk1hY4gBARIuChBlbmNyeXB0aW9uX25vbmNlGAogASgMSAVSD2VuY3J5cHRpb2'
|
||||
'5Ob25jZYgBARI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgLIAEoDEgGUhVhZGRpdGlvbmFs'
|
||||
'TWVzc2FnZURhdGGIAQESJAoLd2lkZ2V0X29ubHkYDCABKAhIB1IKd2lkZ2V0T25seYgBASI+Cg'
|
||||
'RUeXBlEgwKCFJFVVBMT0FEEAASCQoFSU1BR0UQARIJCgVWSURFTxACEgcKA0dJRhADEgkKBUFV'
|
||||
'RElPEARCIAoeX2Rpc3BsYXlfbGltaXRfaW5fbWlsbGlzZWNvbmRzQhMKEV9xdW90ZV9tZXNzYW'
|
||||
'dlX2lkQhEKD19kb3dubG9hZF90b2tlbkIRCg9fZW5jcnlwdGlvbl9rZXlCEQoPX2VuY3J5cHRp'
|
||||
'b25fbWFjQhMKEV9lbmNyeXB0aW9uX25vbmNlQhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YU'
|
||||
'IOCgxfd2lkZ2V0X29ubHkaTgoFU3RvcnkSLQoFbWVkaWEYASABKAsyFy5FbmNyeXB0ZWRDb250'
|
||||
'ZW50Lk1lZGlhUgVtZWRpYRIWCgZub3RpZnkYAiABKAhSBm5vdGlmeRqpAQoLTWVkaWFVcGRhdG'
|
||||
'USNgoEdHlwZRgBIAEoDjIiLkVuY3J5cHRlZENvbnRlbnQuTWVkaWFVcGRhdGUuVHlwZVIEdHlw'
|
||||
'ZRIqChF0YXJnZXRfbWVzc2FnZV9pZBgCIAEoCVIPdGFyZ2V0TWVzc2FnZUlkIjYKBFR5cGUSDA'
|
||||
'oIUkVPUEVORUQQABIKCgZTVE9SRUQQARIUChBERUNSWVBUSU9OX0VSUk9SEAIaeAoOQ29udGFj'
|
||||
'dFJlcXVlc3QSOQoEdHlwZRgBIAEoDjIlLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFJlcXVlc3'
|
||||
'QuVHlwZVIEdHlwZSIrCgRUeXBlEgsKB1JFUVVFU1QQABIKCgZSRUpFQ1QQARIKCgZBQ0NFUFQQ'
|
||||
'AhqkAgoNQ29udGFjdFVwZGF0ZRI4CgR0eXBlGAEgASgOMiQuRW5jcnlwdGVkQ29udGVudC5Db2'
|
||||
'50YWN0VXBkYXRlLlR5cGVSBHR5cGUSNwoVYXZhdGFyX3N2Z19jb21wcmVzc2VkGAIgASgMSABS'
|
||||
'E2F2YXRhclN2Z0NvbXByZXNzZWSIAQESHwoIdXNlcm5hbWUYAyABKAlIAVIIdXNlcm5hbWWIAQ'
|
||||
'ESJgoMZGlzcGxheV9uYW1lGAQgASgJSAJSC2Rpc3BsYXlOYW1liAEBIh8KBFR5cGUSCwoHUkVR'
|
||||
'VUVTVBAAEgoKBlVQREFURRABQhgKFl9hdmF0YXJfc3ZnX2NvbXByZXNzZWRCCwoJX3VzZXJuYW'
|
||||
'1lQg8KDV9kaXNwbGF5X25hbWUarwEKCUZsYW1lU3luYxIjCg1mbGFtZV9jb3VudGVyGAEgASgD'
|
||||
'UgxmbGFtZUNvdW50ZXISOQoZbGFzdF9mbGFtZV9jb3VudGVyX2NoYW5nZRgCIAEoA1IWbGFzdE'
|
||||
'ZsYW1lQ291bnRlckNoYW5nZRIfCgtiZXN0X2ZyaWVuZBgDIAEoCFIKYmVzdEZyaWVuZBIhCgxm'
|
||||
'b3JjZV91cGRhdGUYBCABKAhSC2ZvcmNlVXBkYXRlGk0KD1R5cGluZ0luZGljYXRvchIbCglpc1'
|
||||
'90eXBpbmcYASABKAhSCGlzVHlwaW5nEh0KCmNyZWF0ZWRfYXQYAiABKANSCWNyZWF0ZWRBdBo/'
|
||||
'ChRVc2VyRGlzY292ZXJ5UmVxdWVzdBInCg9jdXJyZW50X3ZlcnNpb24YASABKAxSDmN1cnJlbn'
|
||||
'RWZXJzaW9uGjEKE1VzZXJEaXNjb3ZlcnlVcGRhdGUSGgoIbWVzc2FnZXMYASADKAxSCG1lc3Nh'
|
||||
'Z2VzGj0KFEtleVZlcmlmaWNhdGlvblByb29mEiUKDmNhbGN1bGF0ZWRfbWFjGAEgASgMUg1jYW'
|
||||
'xjdWxhdGVkTWFjGpsBChRQYXNzd29yZExlc3NSZWNvdmVyeRI1ChNyZWNvdmVyeVNlY3JldFNo'
|
||||
'YXJlGAEgASgMSABSE3JlY292ZXJ5U2VjcmV0U2hhcmWIAQESFgoGZGVsZXRlGAIgASgIUgZkZW'
|
||||
'xldGUSHAoJdGhyZXNob2xkGAMgASgDUgl0aHJlc2hvbGRCFgoUX3JlY292ZXJ5U2VjcmV0U2hh'
|
||||
'cmUaMwodUGFzc3dvcmRMZXNzUmVjb3ZlcnlIZWFydGJlYXQSEgoEaGFzaBgBIAEoDFIEaGFzaE'
|
||||
'ILCglfZ3JvdXBfaWRCEQoPX2lzX2RpcmVjdF9jaGF0QhkKF19zZW5kZXJfcHJvZmlsZV9jb3Vu'
|
||||
'dGVyQiAKHl9zZW5kZXJfdXNlcl9kaXNjb3ZlcnlfdmVyc2lvbkIcChpfYXNrX2Zvcl9mcmllbm'
|
||||
'RfcHJvbW90aW9uc0IZChdfd2lkZ2V0X3NoYXJpbmdfYWxsb3dlZEIRCg9fbWVzc2FnZV91cGRh'
|
||||
'dGVCCAoGX21lZGlhQg8KDV9tZWRpYV91cGRhdGVCEQoPX2NvbnRhY3RfdXBkYXRlQhIKEF9jb2'
|
||||
'50YWN0X3JlcXVlc3RCDQoLX2ZsYW1lX3N5bmNCCwoJX3JlYWN0aW9uQg8KDV90ZXh0X21lc3Nh'
|
||||
'Z2VCDwoNX2dyb3VwX2NyZWF0ZUINCgtfZ3JvdXBfam9pbkIPCg1fZ3JvdXBfdXBkYXRlQhoKGF'
|
||||
'9yZXNlbmRfZ3JvdXBfcHVibGljX2tleUIRCg9fZXJyb3JfbWVzc2FnZXNCGgoYX2FkZGl0aW9u'
|
||||
'YWxfZGF0YV9tZXNzYWdlQhMKEV90eXBpbmdfaW5kaWNhdG9yQhkKF191c2VyX2Rpc2NvdmVyeV'
|
||||
'9yZXF1ZXN0QhgKFl91c2VyX2Rpc2NvdmVyeV91cGRhdGVCGQoXX2tleV92ZXJpZmljYXRpb25f'
|
||||
'cHJvb2ZCGAoWX3Bhc3N3b3JkbGVzc19yZWNvdmVyeUIiCiBfcGFzc3dvcmRsZXNzX3JlY292ZX'
|
||||
'J5X2hlYXJ0YmVhdEIICgZfc3RvcnlKBAgcEB0=');
|
||||
|
|
|
|||
|
|
@ -97,6 +97,24 @@ final routerProvider = GoRouter(
|
|||
return MediaViewerView(group);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'story/:contactId',
|
||||
builder: (context, state) {
|
||||
final contactId = int.parse(state.pathParameters['contactId']!);
|
||||
return MediaViewerView.story(
|
||||
StorySource.contact(
|
||||
contactId,
|
||||
initialMediaId: state.extra as String?,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'own_story',
|
||||
builder: (context, state) => MediaViewerView.story(
|
||||
StorySource.own(initialMediaId: state.extra as String?),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'messages/:groupId',
|
||||
builder: (context, state) {
|
||||
|
|
|
|||
|
|
@ -37,11 +37,16 @@ class NativeNotificationService {
|
|||
/// Emits metadata for every native notification tapped while the app runs.
|
||||
static Stream<NativeNotificationTap> get taps => _taps.stream;
|
||||
|
||||
/// Text messages (including quoted replies) and the announcements an app
|
||||
/// writes into a chat open their conversation. Media and every other
|
||||
/// notification kind stay on the chat overview.
|
||||
/// Text messages (including quoted replies, and replies and reactions to
|
||||
/// the user's story) and the announcements an app writes into a chat open
|
||||
/// their conversation. Media and every other notification kind stay on the
|
||||
/// chat overview.
|
||||
static bool opensConversation(String? kind) =>
|
||||
kind == 'text' || kind == 'response' || kind == 'webxdc';
|
||||
kind == 'text' ||
|
||||
kind == 'response' ||
|
||||
kind == 'webxdc' ||
|
||||
kind == 'story_reply' ||
|
||||
kind == 'story_reaction';
|
||||
|
||||
static void init() {
|
||||
_startBadgeSync();
|
||||
|
|
|
|||
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:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/core/bridge/groups.dart' as rust_groups;
|
||||
import 'package:twonly/core/bridge/stories.dart' as stories;
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_request_badge.comp.dart';
|
||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||
import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
|
||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||
import 'package:twonly/src/visual/elements/headline.element.dart';
|
||||
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||
import 'package:twonly/src/visual/elements/my_input.element.dart';
|
||||
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/best_friends_selector.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/story_target_selector.comp.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/background.layer.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart';
|
||||
|
||||
bool canSendSharedMedia({
|
||||
required bool mediaReady,
|
||||
required bool sending,
|
||||
required bool hasChatRecipients,
|
||||
required bool storyPossible,
|
||||
required bool hasStoryTarget,
|
||||
required int? storyAudienceSize,
|
||||
}) {
|
||||
return mediaReady &&
|
||||
!sending &&
|
||||
(hasChatRecipients ||
|
||||
(storyPossible && hasStoryTarget && (storyAudienceSize ?? 0) > 0));
|
||||
}
|
||||
|
||||
/// Every chat a send reaches: those picked directly and, with a story, the
|
||||
/// 1:1 chat of everybody the story goes to. Someone picked directly who also
|
||||
/// gets the story counts once.
|
||||
int sharedMediaRecipientCount({
|
||||
required Set<String> selectedGroupIds,
|
||||
required Set<String>? storyAudienceChats,
|
||||
}) => {...selectedGroupIds, ...?storyAudienceChats}.length;
|
||||
|
||||
class ShareImageView extends StatefulWidget {
|
||||
const ShareImageView({
|
||||
required this.selectedGroupIds,
|
||||
|
|
@ -31,6 +57,7 @@ class ShareImageView extends StatefulWidget {
|
|||
required this.mediaFileService,
|
||||
required this.additionalData,
|
||||
required this.sendToWidget,
|
||||
required this.storyTarget,
|
||||
super.key,
|
||||
});
|
||||
final HashSet<String> selectedGroupIds;
|
||||
|
|
@ -39,6 +66,7 @@ class ShareImageView extends StatefulWidget {
|
|||
final MediaFileService mediaFileService;
|
||||
final AdditionalMessageData? additionalData;
|
||||
final bool sendToWidget;
|
||||
final StoryTarget storyTarget;
|
||||
|
||||
@override
|
||||
State<ShareImageView> createState() => _ShareImageView();
|
||||
|
|
@ -57,6 +85,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
late StreamSubscription<List<Group>> allGroupSub;
|
||||
String lastQuery = '';
|
||||
int _updateGroupsGeneration = 0;
|
||||
Set<String>? _storyAudienceChats;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -96,6 +125,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
@override
|
||||
void dispose() {
|
||||
unawaited(allGroupSub.cancel());
|
||||
searchUserName.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +202,58 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -195,22 +277,35 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
const EmptyChatListComp()
|
||||
else ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: TextField(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: MyInput(
|
||||
controller: searchUserName,
|
||||
dense: true,
|
||||
fontWeight: FontWeight.normal,
|
||||
hintText: context.lang.shareImageSearchAllContacts,
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
onChanged: _filterUsers,
|
||||
decoration: getInputDecoration(
|
||||
context,
|
||||
context.lang.shareImageSearchAllContacts,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (!widget.sendToWidget)
|
||||
const SizedBox(height: 12),
|
||||
if (!widget.sendToWidget) ...[
|
||||
ContactGroupShortcutRow(
|
||||
selectedGroupIds: widget.selectedGroupIds,
|
||||
updateSelectedGroupIds: updateSelectedGroupIds,
|
||||
),
|
||||
if (_pinnedContacts.isNotEmpty) const SizedBox(height: 10),
|
||||
const SizedBox(height: 10),
|
||||
StoryTargetSelector(
|
||||
target: widget.storyTarget,
|
||||
enabled: _storyPossible,
|
||||
onChanged: () => setState(() {}),
|
||||
onAudienceChanged: (chats) {
|
||||
if (!mounted) return;
|
||||
setState(() => _storyAudienceChats = chats);
|
||||
},
|
||||
),
|
||||
// The next section's heading brings its own space.
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
BestFriendsSelector(
|
||||
groups: _pinnedContacts,
|
||||
selectedGroupIds: widget.selectedGroupIds,
|
||||
|
|
@ -220,7 +315,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
!widget.sendToWidget &&
|
||||
!widget.mediaFileService.mediaFile.requiresAuthentication,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (_pinnedContacts.isNotEmpty) const SizedBox(height: 10),
|
||||
BestFriendsSelector(
|
||||
groups: _bestFriends,
|
||||
selectedGroupIds: widget.selectedGroupIds,
|
||||
|
|
@ -230,7 +325,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
!widget.sendToWidget &&
|
||||
!widget.mediaFileService.mediaFile.requiresAuthentication,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (_bestFriends.isNotEmpty) const SizedBox(height: 10),
|
||||
if (_otherUsers.isNotEmpty)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
|
|
@ -319,32 +414,17 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
MyButton(
|
||||
variant: MyButtonVariant.primaryMiddle,
|
||||
onPressed:
|
||||
!mediaStoreFutureReady ||
|
||||
widget.selectedGroupIds.isEmpty ||
|
||||
sendingImage
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
sendingImage = true;
|
||||
});
|
||||
|
||||
// in case mediaStoreFutureReady is ready, the image is stored in the originalPath
|
||||
unawaitedRustCall(
|
||||
RustApi.sendMediaToGroups(
|
||||
mediaId:
|
||||
widget.mediaFileService.mediaFile.mediaId,
|
||||
groupIds: widget.selectedGroupIds.toList(),
|
||||
additionalMessageData: widget.additionalData
|
||||
?.writeToBuffer(),
|
||||
widgetOnly: widget.sendToWidget,
|
||||
),
|
||||
'sendMediaToGroups',
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
},
|
||||
canSendSharedMedia(
|
||||
mediaReady: mediaStoreFutureReady,
|
||||
sending: sendingImage,
|
||||
hasChatRecipients:
|
||||
widget.selectedGroupIds.isNotEmpty,
|
||||
storyPossible: _storyPossible,
|
||||
hasStoryTarget: !widget.storyTarget.isEmpty,
|
||||
storyAudienceSize: _storyAudienceChats?.length,
|
||||
)
|
||||
? _send
|
||||
: null,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -366,7 +446,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
),
|
||||
const SizedBox(width: 8),
|
||||
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/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/elements/my_chip.element.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/first_seen_order.dart';
|
||||
import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart';
|
||||
|
||||
class ContactGroupShortcutRow extends StatefulWidget {
|
||||
|
|
@ -24,6 +26,11 @@ class ContactGroupShortcutRow extends StatefulWidget {
|
|||
|
||||
class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
||||
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;
|
||||
|
||||
@override
|
||||
|
|
@ -32,7 +39,11 @@ class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
|||
_subscription = twonlyDB.contactGroupsDao
|
||||
.watchShortcutContactGroups()
|
||||
.listen((contactGroups) {
|
||||
if (mounted) setState(() => _contactGroups = contactGroups);
|
||||
if (!mounted) return;
|
||||
setState(
|
||||
() => _contactGroups = inFirstSeenOrder(contactGroups, _order),
|
||||
);
|
||||
unawaited(_loadTargets());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -42,17 +53,17 @@ class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openSettings([ContactGroup? contactGroup]) async {
|
||||
await context.navPush(
|
||||
ContactGroupSettingsView(
|
||||
contactGroup: contactGroup,
|
||||
initialShowAsShortcut: contactGroup == null,
|
||||
),
|
||||
);
|
||||
Future<void> _loadTargets() async {
|
||||
final generation = ++_targetsGeneration;
|
||||
final targets = {
|
||||
for (final contactGroup in _contactGroups)
|
||||
contactGroup.id: await _resolveTargets(contactGroup),
|
||||
};
|
||||
if (!mounted || generation != _targetsGeneration) return;
|
||||
setState(() => _targets = targets);
|
||||
}
|
||||
|
||||
Future<void> _apply(ContactGroup contactGroup) async {
|
||||
await twonlyDB.contactGroupsDao.incrementUsage(contactGroup.id);
|
||||
Future<Set<String>> _resolveTargets(ContactGroup contactGroup) async {
|
||||
final members = await twonlyDB.contactGroupsDao.getMembers(contactGroup.id);
|
||||
final targetGroupIds = <String>{};
|
||||
for (final member in members) {
|
||||
|
|
@ -65,6 +76,38 @@ class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
|||
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()) {
|
||||
widget.updateSelectedGroupIds(groupId, false);
|
||||
}
|
||||
|
|
@ -76,37 +119,32 @@ class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
children: [
|
||||
ActionChip(
|
||||
padding: EdgeInsets.zero,
|
||||
MyChip(
|
||||
tooltip: context.lang.createContactGroup,
|
||||
onPressed: _openSettings,
|
||||
onTap: _openSettings,
|
||||
label: _contactGroups.isEmpty
|
||||
? Text(
|
||||
context.lang.createContactGroup,
|
||||
style: const TextStyle(fontSize: 9),
|
||||
)
|
||||
: const Icon(Icons.add_reaction_outlined, size: 20),
|
||||
shape: const StadiumBorder(),
|
||||
? Text(context.lang.createContactGroup)
|
||||
: const Icon(Icons.add_reaction_outlined),
|
||||
),
|
||||
for (final contactGroup in _contactGroups)
|
||||
GestureDetector(
|
||||
onLongPress: () => _openSettings(contactGroup),
|
||||
child: ActionChip(
|
||||
padding: EdgeInsets.zero,
|
||||
for (final contactGroup in _contactGroups) ...[
|
||||
const SizedBox(width: 8),
|
||||
MyChip(
|
||||
tooltip: contactGroup.name,
|
||||
onPressed: () => _apply(contactGroup),
|
||||
selected: _isSelected(_targets[contactGroup.id]),
|
||||
onTap: () => _apply(contactGroup),
|
||||
onLongPress: () => _openSettings(contactGroup),
|
||||
label: Text(
|
||||
contactGroup.emoji ?? contactGroup.name,
|
||||
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/views/camera/camera_preview_components/main_camera_controller.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_contact_selection.view.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/story_target_selector.comp.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/discard_media_dialog.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/display_time_picker.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart';
|
||||
|
|
@ -67,6 +68,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
bool loadingImage = true;
|
||||
bool isDisposed = false;
|
||||
HashSet<String> selectedGroupIds = HashSet();
|
||||
final StoryTarget storyTarget = StoryTarget();
|
||||
double pixelRatio = 1;
|
||||
VideoPlayerController? videoController;
|
||||
|
||||
|
|
@ -352,6 +354,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
Future<void> _toggleRequiresAuth() async {
|
||||
await mediaService.setRequiresAuth(!media.requiresAuthentication);
|
||||
selectedGroupIds = HashSet();
|
||||
storyTarget.clear();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
|
|
@ -361,6 +364,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
if (widget.sendToGroup == null) {
|
||||
selectedGroupIds.clear();
|
||||
}
|
||||
storyTarget.clear();
|
||||
|
||||
if (enabled) {
|
||||
_displayLimitBeforeWidget = media.displayLimitInMilliseconds;
|
||||
|
|
@ -467,6 +471,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
mediaFileService: mediaService,
|
||||
additionalData: getAdditionalData(),
|
||||
sendToWidget: _sendToWidget,
|
||||
storyTarget: storyTarget,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
|
|
@ -8,6 +9,7 @@ import 'package:provider/provider.dart';
|
|||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/constants/routes.keys.dart';
|
||||
import 'package:twonly/src/database/daos/key_verification.dao.dart';
|
||||
import 'package:twonly/src/database/daos/stories.dao.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/providers/purchases.provider.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
|
|
@ -17,10 +19,12 @@ import 'package:twonly/src/utils/misc.dart';
|
|||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/connection_status.comp.dart';
|
||||
import 'package:twonly/src/visual/components/notification_badge.comp.dart';
|
||||
import 'package:twonly/src/visual/components/story_preview.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_list_components/news_btn.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart';
|
||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart';
|
||||
import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart';
|
||||
import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart';
|
||||
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart';
|
||||
|
|
@ -46,6 +50,9 @@ class _ChatListViewState extends State<ChatListView>
|
|||
StreamSubscription<Map<String, VerificationStatus>>? _verificationSub;
|
||||
StreamSubscription<List<(int, ContactGroup)>>? _contactGroupsSub;
|
||||
StreamSubscription<List<(String, ContactGroup)>>? _chatContactGroupsSub;
|
||||
StreamSubscription<List<StoryItem>>? _storiesSub;
|
||||
StreamSubscription<List<OwnStoryItem>>? _ownStoriesSub;
|
||||
final StoryExpiryTimer _ownStoryExpiry = StoryExpiryTimer();
|
||||
Timer? _typingUpdateTimer;
|
||||
final Set<String> _precachedMediaIds = {};
|
||||
List<Group> _groupsNotPinned = [];
|
||||
|
|
@ -61,6 +68,8 @@ class _ChatListViewState extends State<ChatListView>
|
|||
Map<String, VerificationStatus> _verificationByGroup = {};
|
||||
Map<int, List<ContactGroup>> _contactGroupsByUser = {};
|
||||
Map<String, List<ContactGroup>> _contactGroupsByGroup = {};
|
||||
Map<String, List<StoryItem>> _storiesByGroup = {};
|
||||
List<OwnStoryItem> _ownStories = [];
|
||||
|
||||
final ValueNotifier<bool> _hasContacts = ValueNotifier(false);
|
||||
bool _loading = true;
|
||||
|
|
@ -231,6 +240,42 @@ class _ChatListViewState extends State<ChatListView>
|
|||
}
|
||||
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
|
||||
|
|
@ -265,6 +310,9 @@ class _ChatListViewState extends State<ChatListView>
|
|||
_verificationSub?.cancel();
|
||||
_contactGroupsSub?.cancel();
|
||||
_chatContactGroupsSub?.cancel();
|
||||
_storiesSub?.cancel();
|
||||
_ownStoriesSub?.cancel();
|
||||
_ownStoryExpiry.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -307,12 +355,22 @@ class _ChatListViewState extends State<ChatListView>
|
|||
children: [
|
||||
ConnectionStatusComp(
|
||||
child: GestureDetector(
|
||||
// The profile is where the user's own story is followed.
|
||||
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,
|
||||
fontSize: 14,
|
||||
color: context.color.onSurface.withAlpha(20),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
|
@ -446,6 +504,7 @@ class _ChatListViewState extends State<ChatListView>
|
|||
useSharedSummary: true,
|
||||
verificationStatus: _verificationByGroup[group.groupId],
|
||||
contactGroups: _contactGroupsFor(group),
|
||||
storyItems: _storiesByGroup[group.groupId] ?? const [],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -475,6 +534,7 @@ class _ChatListViewState extends State<ChatListView>
|
|||
useSharedSummary: true,
|
||||
verificationStatus: _verificationByGroup[group.groupId],
|
||||
contactGroups: _contactGroupsFor(group),
|
||||
storyItems: _storiesByGroup[group.groupId] ?? const [],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -7,6 +8,7 @@ import 'package:go_router/go_router.dart';
|
|||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/constants/routes.keys.dart';
|
||||
import 'package:twonly/src/database/daos/key_verification.dao.dart';
|
||||
import 'package:twonly/src/database/daos/stories.dao.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
|
|
@ -15,11 +17,13 @@ import 'package:twonly/src/utils/misc.dart';
|
|||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||
import 'package:twonly/src/visual/components/story_preview.comp.dart';
|
||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||
import 'package:twonly/src/visual/context_menu/group.context_menu.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_list_components/last_message_time.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_list_components/typing_indicator_subtitle.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart';
|
||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart';
|
||||
|
||||
class GroupListItemComp extends StatefulWidget {
|
||||
const GroupListItemComp({
|
||||
|
|
@ -33,6 +37,7 @@ class GroupListItemComp extends StatefulWidget {
|
|||
this.useSharedSummary = false,
|
||||
this.verificationStatus,
|
||||
this.contactGroups = const [],
|
||||
this.storyItems,
|
||||
super.key,
|
||||
});
|
||||
final Group group;
|
||||
|
|
@ -46,6 +51,9 @@ class GroupListItemComp extends StatefulWidget {
|
|||
final VerificationStatus? verificationStatus;
|
||||
final List<ContactGroup> contactGroups;
|
||||
|
||||
/// The contact's story items in a direct chat. Null subscribes itself.
|
||||
final List<StoryItem>? storyItems;
|
||||
|
||||
@override
|
||||
State<GroupListItemComp> createState() => _UserListItem();
|
||||
}
|
||||
|
|
@ -63,6 +71,9 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
StreamSubscription<List<MediaFile>>? _lastMediaFilesStream;
|
||||
Contact? _directContact;
|
||||
StreamSubscription<List<Contact>>? _directContactStream;
|
||||
List<StoryItem> _storyItems = const [];
|
||||
StreamSubscription<List<StoryItem>>? _storyItemsStream;
|
||||
final StoryExpiryTimer _storyExpiry = StoryExpiryTimer();
|
||||
|
||||
List<Message> _previewMessages = [];
|
||||
final List<MediaFile> _previewMediaFiles = [];
|
||||
|
|
@ -75,6 +86,8 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
_applyContacts();
|
||||
_lastReaction = widget.lastReaction;
|
||||
_lastMessage = widget.lastMessage;
|
||||
_storyItems = widget.storyItems ?? const [];
|
||||
_scheduleStoryExpiry();
|
||||
_applyMediaFiles();
|
||||
if (widget.useSharedSummary) {
|
||||
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
|
||||
|
|
@ -95,6 +108,11 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
|
||||
}
|
||||
if (widget.mediaFiles != oldWidget.mediaFiles) _applyMediaFiles();
|
||||
if (widget.storyItems != null &&
|
||||
widget.storyItems != oldWidget.storyItems) {
|
||||
_storyItems = widget.storyItems!;
|
||||
_scheduleStoryExpiry();
|
||||
}
|
||||
}
|
||||
|
||||
void _applyContacts() {
|
||||
|
|
@ -120,10 +138,22 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
_lastMessageStream?.cancel();
|
||||
_lastMediaFilesStream?.cancel();
|
||||
_directContactStream?.cancel();
|
||||
_storyItemsStream?.cancel();
|
||||
_storyExpiry.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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) {
|
||||
final lastMsgStream = await twonlyDB.messagesDao.watchLastMessage(
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<void>(
|
||||
|
|
@ -414,21 +510,8 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
],
|
||||
),
|
||||
leading: GestureDetector(
|
||||
onTap: () async {
|
||||
if (widget.group.isDirectChat) {
|
||||
final contacts = await twonlyDB.groupsDao.getGroupContact(
|
||||
widget.group.groupId,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
await context.push(
|
||||
Routes.profileContact(contacts.first.userId),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
await context.push(Routes.profileGroup(widget.group.groupId));
|
||||
}
|
||||
},
|
||||
child: AvatarIcon(group: widget.group, contacts: widget.contacts),
|
||||
onTap: _onAvatarTap,
|
||||
child: _avatar(),
|
||||
),
|
||||
trailing: (widget.group.leftGroup || _receiverDeletedAccount)
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
|
|
@ -10,6 +11,7 @@ import 'package:twonly/globals.dart';
|
|||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/constants/routes.keys.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/daos/stories.dao.dart';
|
||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/memory_item.model.dart';
|
||||
|
|
@ -563,6 +565,25 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
}
|
||||
|
||||
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(
|
||||
(x) => x.isMessage && x.message!.messageId == messageId,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/daos/stories.dao.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
|
|
@ -80,7 +82,9 @@ class ResponseContainer extends StatelessWidget {
|
|||
contact: quotedMessage?.senderId == null
|
||||
? null
|
||||
: contactsById?[quotedMessage!.senderId],
|
||||
useSharedData: useSharedData,
|
||||
// A quote the loaded page does not hold, such as a story
|
||||
// the chat never lists, is looked up by the preview.
|
||||
useSharedData: useSharedData && quotedMessage != null,
|
||||
showBorder: false,
|
||||
showLeftBorder: false,
|
||||
),
|
||||
|
|
@ -169,6 +173,17 @@ class _ResponsePreviewState extends State<ResponsePreview> {
|
|||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Whether the quoted story can still be shown. Once it expired, or its
|
||||
/// sender took it down, only the fact that it was a story remains.
|
||||
bool get _storyAlive {
|
||||
final message = _message;
|
||||
if (message == null || !message.isStory) return false;
|
||||
if (message.mediaStored) return true;
|
||||
return message.mediaId != null &&
|
||||
_mediaService != null &&
|
||||
clock.now().isBefore(message.createdAt.add(storyLifetime));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String? subtitle;
|
||||
|
|
@ -219,21 +234,28 @@ class _ResponsePreviewState extends State<ResponsePreview> {
|
|||
_username = context.lang.you;
|
||||
}
|
||||
|
||||
if (_message!.isStory) {
|
||||
subtitle = _storyAlive ? context.lang.story : context.lang.storyExpired;
|
||||
}
|
||||
|
||||
color = getMessageColor(_message!.senderId != null);
|
||||
}
|
||||
|
||||
final hasImage =
|
||||
_message != null &&
|
||||
_message!.mediaStored &&
|
||||
(_message!.mediaStored || _storyAlive) &&
|
||||
_mediaService != null &&
|
||||
_mediaService!.mediaFile.type != MediaType.audio;
|
||||
|
||||
Widget? imageWidget;
|
||||
if (hasImage) {
|
||||
final isVideo = _mediaService!.mediaFile.type == MediaType.video;
|
||||
// A story nobody saved is still in its temporary file.
|
||||
final pathToCheck = isVideo
|
||||
? _mediaService!.thumbnailPath
|
||||
: _mediaService!.storedPath;
|
||||
: _message!.mediaStored
|
||||
? _mediaService!.storedPath
|
||||
: _mediaService!.tempPath;
|
||||
imageWidget = Container(
|
||||
height: 40,
|
||||
width: 40,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:screen_protector/screen_protector.dart';
|
|||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/constants/routes.keys.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/daos/stories.dao.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart'
|
||||
show DownloadState, MediaType;
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
|
|
@ -28,15 +29,40 @@ import 'package:twonly/src/visual/views/chats/media_viewer_components/keyboard_d
|
|||
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_bottom_navigation.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/own_story_bottom_bar.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/story_expiry_timer.dart';
|
||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// What a story viewer plays: one contact's active story, or the user's own.
|
||||
class StorySource {
|
||||
const StorySource.contact(int this.contactId, {this.initialMediaId})
|
||||
: own = false;
|
||||
const StorySource.own({this.initialMediaId}) : contactId = null, own = true;
|
||||
|
||||
final int? contactId;
|
||||
final bool own;
|
||||
|
||||
/// The item to start at. Without one a contact's story starts at the first
|
||||
/// item not seen yet, and the user's own story at its oldest item.
|
||||
final String? initialMediaId;
|
||||
}
|
||||
|
||||
class MediaViewerView extends StatefulWidget {
|
||||
const MediaViewerView(this.group, {super.key, this.initialMessage});
|
||||
final Group group;
|
||||
const MediaViewerView(Group this.group, {super.key, this.initialMessage})
|
||||
: story = null;
|
||||
|
||||
/// Plays a story instead of a chat's unopened media. Items replay until
|
||||
/// they expire, so nothing here is removed after it was shown.
|
||||
const MediaViewerView.story(StorySource this.story, {super.key})
|
||||
: group = null,
|
||||
initialMessage = null;
|
||||
|
||||
final Group? group;
|
||||
|
||||
final Message? initialMessage;
|
||||
final StorySource? story;
|
||||
@override
|
||||
State<MediaViewerView> createState() => _MediaViewerViewState();
|
||||
}
|
||||
|
|
@ -44,6 +70,7 @@ class MediaViewerView extends StatefulWidget {
|
|||
class _MediaViewerViewState extends State<MediaViewerView> {
|
||||
Timer? nextMediaTimer;
|
||||
Timer? progressTimer;
|
||||
final StoryExpiryTimer _storyExpiryTimer = StoryExpiryTimer();
|
||||
|
||||
bool showShortReactions = false;
|
||||
double mediaViewerDistanceFromBottom = 0;
|
||||
|
|
@ -79,10 +106,23 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
|
||||
bool _isTransitioning = false;
|
||||
|
||||
/// The chat replies go to. For a contact's story it is the direct chat the
|
||||
/// story rows are filed under, looked up once the story is loaded.
|
||||
Group? _group;
|
||||
|
||||
bool get _isStory => widget.story != null;
|
||||
bool get _isOwnStory => widget.story?.own ?? false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentMediaSender = widget.group.groupName;
|
||||
_group = widget.group;
|
||||
_currentMediaSender = widget.group?.groupName ?? '';
|
||||
|
||||
if (_isStory) {
|
||||
unawaited(_loadStory());
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.initialMessage != null &&
|
||||
!widget.initialMessage!.isWidgetMedia) {
|
||||
|
|
@ -96,6 +136,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
void dispose() {
|
||||
nextMediaTimer?.cancel();
|
||||
progressTimer?.cancel();
|
||||
_storyExpiryTimer.cancel();
|
||||
_subscription?.cancel();
|
||||
downloadStateListener?.cancel();
|
||||
progress.dispose();
|
||||
|
|
@ -106,14 +147,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
|
||||
// Persist draft message on close
|
||||
final draftText = textMessageController.text;
|
||||
if (!_isStory) {
|
||||
unawaited(
|
||||
twonlyDB.groupsDao.updateGroup(
|
||||
widget.group.groupId,
|
||||
widget.group!.groupId,
|
||||
GroupsCompanion(
|
||||
draftMessage: Value(draftText.isEmpty ? null : draftText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
textMessageController.dispose();
|
||||
|
||||
super.dispose();
|
||||
|
|
@ -132,9 +175,54 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
|
||||
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 {
|
||||
_subscription = twonlyDB.messagesDao
|
||||
.watchMediaNotOpened(widget.group.groupId)
|
||||
.watchMediaNotOpened(widget.group!.groupId)
|
||||
.listen((messages) async {
|
||||
await _messageUpdateLock.protect(() async {
|
||||
for (final msg in messages) {
|
||||
|
|
@ -193,8 +281,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
_isTransitioning = true;
|
||||
|
||||
try {
|
||||
/// Remove the current media file in case it is not set to unlimited
|
||||
if (currentMedia != null) {
|
||||
/// Remove the current media file in case it is not set to unlimited.
|
||||
/// A story replays until it expires, whatever the file says.
|
||||
if (currentMedia != null && !_isStory) {
|
||||
if (!imageSaved &&
|
||||
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
||||
await currentMedia!.fullMediaRemoval();
|
||||
|
|
@ -207,9 +296,12 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
|
||||
nextMediaTimer?.cancel();
|
||||
progressTimer?.cancel();
|
||||
_storyExpiryTimer.cancel();
|
||||
|
||||
if (allMediaFiles.isEmpty) {
|
||||
final group = await twonlyDB.groupsDao.getGroup(widget.group.groupId);
|
||||
if (allMediaFiles.isEmpty && _isStory) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
} else if (allMediaFiles.isEmpty) {
|
||||
final group = await twonlyDB.groupsDao.getGroup(widget.group!.groupId);
|
||||
if (mounted) {
|
||||
if (group != null &&
|
||||
group.draftMessage != null &&
|
||||
|
|
@ -232,6 +324,24 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
|
||||
Future<void> loadAndDownloadCurrentMedia({bool showTwonly = false}) async {
|
||||
if (!mounted || !context.mounted) return;
|
||||
if (_isStory) {
|
||||
final now = clock.now();
|
||||
while (allMediaFiles.isNotEmpty &&
|
||||
!allMediaFiles.first.createdAt.add(storyLifetime).isAfter(now)) {
|
||||
allMediaFiles.removeAt(0);
|
||||
}
|
||||
if (allMediaFiles.isEmpty) {
|
||||
Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
_storyExpiryTimer.schedule(
|
||||
postedAt: allMediaFiles.first.createdAt,
|
||||
now: now,
|
||||
onExpired: () {
|
||||
if (mounted) unawaited(advanceToNextMediaOrExit());
|
||||
},
|
||||
);
|
||||
}
|
||||
if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) {
|
||||
return advanceToNextMediaOrExit();
|
||||
}
|
||||
|
|
@ -270,7 +380,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
await advanceToNextMediaOrExit();
|
||||
return;
|
||||
}
|
||||
if (updated.downloadState != DownloadState.ready) {
|
||||
// The user's own story plays from the file they sent; there is
|
||||
// nothing to download.
|
||||
if (!_isOwnStory && updated.downloadState != DownloadState.ready) {
|
||||
setState(() {
|
||||
_showDownloadingLoader = true;
|
||||
});
|
||||
|
|
@ -354,20 +466,25 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
Log.warn(
|
||||
'Temp media file not found for media ID: ${currentMediaLocal.mediaFile.mediaId}',
|
||||
);
|
||||
if (!_isOwnStory) {
|
||||
await RustApi.requestMediaReupload(
|
||||
mediaId: currentMediaLocal.mediaFile.mediaId,
|
||||
);
|
||||
}
|
||||
return advanceToNextMediaOrExit();
|
||||
}
|
||||
|
||||
// The server can now delete the encrypted bytes, as the user has successfully opened it.
|
||||
final downloadToken = currentMediaLocal.mediaFile.downloadToken;
|
||||
if (downloadToken != null) {
|
||||
Log.info(
|
||||
'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}',
|
||||
);
|
||||
unawaitedRustCall(
|
||||
RustApi.downloadDone(token: currentMediaLocal.mediaFile.downloadToken!),
|
||||
RustApi.downloadDone(token: downloadToken),
|
||||
'downloadDone',
|
||||
);
|
||||
}
|
||||
|
||||
if (currentMediaLocal.mediaFile.type == MediaType.video) {
|
||||
await _setupVideoPlayer(currentMediaLocal);
|
||||
|
|
@ -383,21 +500,25 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
}
|
||||
|
||||
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(
|
||||
currentMessage!.senderId!,
|
||||
);
|
||||
if (mounted && sender != null) {
|
||||
_currentMediaSender =
|
||||
'${getContactDisplayName(sender)} (${widget.group.groupName})';
|
||||
'${getContactDisplayName(sender)} (${group.groupName})';
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
|
||||
if (!_isStory &&
|
||||
userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
|
||||
mediaLocal.mediaFile.storedFileHash != null) {
|
||||
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
|
||||
mediaLocal.mediaFile.storedFileHash!,
|
||||
|
|
@ -432,7 +553,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
);
|
||||
|
||||
await controller.setLooping(
|
||||
mediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
||||
_isStory || mediaLocal.mediaFile.displayLimitInMilliseconds == null,
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
|
|
@ -458,7 +579,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
progress.value = 1 - ctrl.value.position.inSeconds / duration;
|
||||
}
|
||||
|
||||
if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
||||
if (!_isStory &&
|
||||
mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
||||
if (ctrl.value.position == ctrl.value.duration) {
|
||||
advanceToNextMediaOrExit();
|
||||
}
|
||||
|
|
@ -482,7 +604,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
}
|
||||
|
||||
void _setupImageTimer(MediaFileService mediaLocal) {
|
||||
if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
||||
// A story image stays until the viewer moves on.
|
||||
if (!_isStory && mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
||||
canBeSeenUntil = clock.now().add(
|
||||
Duration(
|
||||
milliseconds: mediaLocal.mediaFile.displayLimitInMilliseconds!,
|
||||
|
|
@ -534,7 +657,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
),
|
||||
);
|
||||
await RustApi.sendEncryptedContentToGroup(
|
||||
groupId: widget.group.groupId,
|
||||
groupId: msg.groupId,
|
||||
content: pb.EncryptedContent(
|
||||
mediaUpdate: pb.EncryptedContent_MediaUpdate(
|
||||
type: pb.EncryptedContent_MediaUpdate_Type.STORED,
|
||||
|
|
@ -561,6 +684,13 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
}
|
||||
|
||||
Widget bottomNavigation() {
|
||||
if (_isOwnStory) {
|
||||
return OwnStoryBottomBar(
|
||||
key: mediaWidgetKey,
|
||||
mediaFile: currentMedia?.mediaFile,
|
||||
onDeleted: advanceToNextMediaOrExit,
|
||||
);
|
||||
}
|
||||
return MediaViewerBottomNavigationBar(
|
||||
key: mediaWidgetKey,
|
||||
currentMedia: currentMedia,
|
||||
|
|
@ -589,15 +719,18 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
progressTimer?.cancel();
|
||||
await videoController?.pause();
|
||||
if (!mounted) return;
|
||||
final group = _group;
|
||||
if (group == null) return;
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return CameraSendToView(widget.group);
|
||||
return CameraSendToView(group);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (mounted &&
|
||||
!_isStory &&
|
||||
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
||||
await advanceToNextMediaOrExit();
|
||||
} else {
|
||||
|
|
@ -624,7 +757,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
if (textMessageController.text.isNotEmpty) {
|
||||
unawaitedRustCall(
|
||||
RustApi.insertAndSendText(
|
||||
groupId: widget.group.groupId,
|
||||
groupId: currentMessage!.groupId,
|
||||
text: textMessageController.text,
|
||||
quoteMessageId: currentMessage!.messageId,
|
||||
),
|
||||
|
|
@ -700,7 +833,10 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
if (currentMedia != null &&
|
||||
currentMedia?.mediaFile.downloadState != DownloadState.ready)
|
||||
Positioned.fill(child: _loader()),
|
||||
if (canBeSeenUntil != null || progress.value >= 0)
|
||||
// A story image has no clock to show; a video still shows how far
|
||||
// it has played.
|
||||
if ((canBeSeenUntil != null || progress.value >= 0) &&
|
||||
!(_isStory && videoController == null))
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: 27,
|
||||
|
|
@ -755,13 +891,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
),
|
||||
if (currentMessage != null)
|
||||
AdditionalMessageContent(currentMessage!),
|
||||
if (currentMedia != null)
|
||||
if (currentMedia != null && !_isOwnStory)
|
||||
ReactionButtons(
|
||||
show: showShortReactions,
|
||||
textInputFocused: showSendTextMessageInput,
|
||||
mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom,
|
||||
groupId: widget.group.groupId,
|
||||
groupId: currentMessage!.groupId,
|
||||
messageId: currentMessage!.messageId,
|
||||
// A story row never shows in the chat, so a reaction to it
|
||||
// goes there as a message quoting the story instead.
|
||||
asQuotedText: _isStory,
|
||||
emojiKey: emojiKey,
|
||||
hide: () {
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,17 @@ Offset getGlobalOffset(GlobalKey targetKey) {
|
|||
Future<void> sendReaction(
|
||||
String groupId,
|
||||
String messageId,
|
||||
String emoji,
|
||||
) async {
|
||||
String emoji, {
|
||||
bool asQuotedText = false,
|
||||
}) async {
|
||||
if (asQuotedText) {
|
||||
await RustApi.insertAndSendText(
|
||||
groupId: groupId,
|
||||
text: emoji,
|
||||
quoteMessageId: messageId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await twonlyDB.reactionsDao.updateMyReaction(
|
||||
messageId,
|
||||
emoji,
|
||||
|
|
@ -49,6 +58,7 @@ class EmojiReactionWidget extends StatefulWidget {
|
|||
required this.show,
|
||||
required this.emoji,
|
||||
required this.emojiKey,
|
||||
this.asQuotedText = false,
|
||||
super.key,
|
||||
});
|
||||
final String messageId;
|
||||
|
|
@ -57,6 +67,7 @@ class EmojiReactionWidget extends StatefulWidget {
|
|||
final bool show;
|
||||
final String emoji;
|
||||
final GlobalKey<EmojiFloatWidgetState> emojiKey;
|
||||
final bool asQuotedText;
|
||||
|
||||
@override
|
||||
State<EmojiReactionWidget> createState() => _EmojiReactionWidgetState();
|
||||
|
|
@ -70,7 +81,12 @@ class _EmojiReactionWidgetState extends State<EmojiReactionWidget> {
|
|||
return GestureDetector(
|
||||
key: _targetKey,
|
||||
onTap: () async {
|
||||
await sendReaction(widget.groupId, widget.messageId, widget.emoji);
|
||||
await sendReaction(
|
||||
widget.groupId,
|
||||
widget.messageId,
|
||||
widget.emoji,
|
||||
asQuotedText: widget.asQuotedText,
|
||||
);
|
||||
widget.emojiKey.currentState?.spawn(
|
||||
getGlobalOffset(_targetKey),
|
||||
widget.emoji,
|
||||
|
|
|
|||
|
|
@ -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.emojiKey,
|
||||
required this.hide,
|
||||
this.asQuotedText = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -32,6 +33,9 @@ class ReactionButtons extends StatefulWidget {
|
|||
final String groupId;
|
||||
final void Function() hide;
|
||||
|
||||
/// Send the emoji as a message quoting the target instead of a reaction.
|
||||
final bool asQuotedText;
|
||||
|
||||
@override
|
||||
State<ReactionButtons> createState() => _ReactionButtonsState();
|
||||
}
|
||||
|
|
@ -123,6 +127,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
|||
show: _renderAnimations,
|
||||
emoji: emoji as String,
|
||||
emojiKey: widget.emojiKey,
|
||||
asQuotedText: widget.asQuotedText,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
|
|
@ -140,6 +145,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
|||
show: _renderAnimations,
|
||||
emoji: emoji,
|
||||
emojiKey: widget.emojiKey,
|
||||
asQuotedText: widget.asQuotedText,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
|
|
@ -160,6 +166,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
|||
widget.groupId,
|
||||
widget.messageId,
|
||||
layer.text,
|
||||
asQuotedText: widget.asQuotedText,
|
||||
);
|
||||
widget.emojiKey.currentState?.spawn(
|
||||
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:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/memory_item.model.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/recipient_status_row.comp.dart';
|
||||
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/message_history.bottom_sheet.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
|
||||
|
|
@ -164,38 +163,11 @@ class _MessageInfoViewState extends State<MessageInfoView> {
|
|||
}
|
||||
|
||||
columns.add(
|
||||
Padding(
|
||||
RecipientStatusRow(
|
||||
key: ValueKey(groupMember.$1.contactId),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
AvatarIcon(
|
||||
contactId: groupMember.$2.userId,
|
||||
fontSize: 15,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
getContactDisplayName(groupMember.$2),
|
||||
style: const TextStyle(fontSize: 17),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
friendlyDateTime(context, actionAt),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
Text(actionTypeText),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
contact: groupMember.$2,
|
||||
status: actionTypeText,
|
||||
at: actionAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,13 @@ class ContactGroupSettingsView extends StatefulWidget {
|
|||
const ContactGroupSettingsView({
|
||||
this.contactGroup,
|
||||
this.initialShowAsShortcut = false,
|
||||
this.initialShareStories = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ContactGroup? contactGroup;
|
||||
final bool initialShowAsShortcut;
|
||||
final bool initialShareStories;
|
||||
|
||||
@override
|
||||
State<ContactGroupSettingsView> createState() =>
|
||||
|
|
@ -69,6 +71,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
late int _textColor;
|
||||
late bool _showAsShortcut;
|
||||
late bool _showAsLabel;
|
||||
late bool _shareStories;
|
||||
String? _emoji;
|
||||
bool _saving = false;
|
||||
String _memberFilter = '';
|
||||
|
|
@ -96,6 +99,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
_showAsShortcut =
|
||||
contactGroup?.showAsShortcut ?? widget.initialShowAsShortcut;
|
||||
_showAsLabel = contactGroup?.showAsLabel ?? !widget.initialShowAsShortcut;
|
||||
_shareStories = contactGroup?.shareStories ?? widget.initialShareStories;
|
||||
_emoji = contactGroup?.emoji;
|
||||
if (contactGroup != null) unawaited(_loadWidgetUsage(contactGroup.id));
|
||||
|
||||
|
|
@ -226,6 +230,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
backgroundColor: _backgroundColor,
|
||||
showAsShortcut: _showAsShortcut,
|
||||
showAsLabel: _showAsLabel,
|
||||
shareStories: _shareStories,
|
||||
);
|
||||
if (_isEditing) {
|
||||
await twonlyDB.contactGroupsDao.updateContactGroup(
|
||||
|
|
@ -236,6 +241,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
backgroundColor: _backgroundColor,
|
||||
showAsShortcut: _showAsShortcut,
|
||||
showAsLabel: _showAsLabel,
|
||||
shareStories: _shareStories,
|
||||
);
|
||||
}
|
||||
await twonlyDB.contactGroupsDao.replaceMembers(
|
||||
|
|
@ -528,6 +534,13 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
value: _showAsShortcut,
|
||||
onChanged: (value) => setState(() => _showAsShortcut = value),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.lang.contactGroupShareStories),
|
||||
subtitle: Text(context.lang.contactGroupShareStoriesSubtitle),
|
||||
value: _shareStories,
|
||||
onChanged: (value) => setState(() => _shareStories = value),
|
||||
),
|
||||
if (_showAsShortcut)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:twonly/src/database/twonly.db.dart';
|
|||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||
import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart';
|
||||
|
||||
class SelectContactGroupsView extends StatefulWidget {
|
||||
|
|
@ -193,10 +194,17 @@ class _SelectContactGroupsViewState extends State<SelectContactGroupsView> {
|
|||
Expanded(
|
||||
child: _contactGroups.isEmpty
|
||||
? Center(
|
||||
child: FilledButton.icon(
|
||||
child: MyButton(
|
||||
variant: MyButtonVariant.primaryDense,
|
||||
onPressed: _openSettings,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(context.lang.createContactGroup),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.lang.createContactGroup),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
|
|
@ -209,6 +217,8 @@ class _SelectContactGroupsViewState extends State<SelectContactGroupsView> {
|
|||
context.lang.contactGroupLabelFeature,
|
||||
if (contactGroup.showAsShortcut)
|
||||
context.lang.contactGroupShortcutFeature,
|
||||
if (contactGroup.shareStories)
|
||||
context.lang.contactGroupStoriesFeature,
|
||||
].join(' · ');
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
|||
double _offsetFromOne = 0;
|
||||
bool _isBottomNavVisible = true;
|
||||
Timer? _disableCameraTimer;
|
||||
|
||||
/// Whether [_openEditor] has the editor open over this view.
|
||||
bool _editorOpen = false;
|
||||
bool _startPreloading = false;
|
||||
|
||||
final MainCameraController _mainCameraController = MainCameraController();
|
||||
|
|
@ -153,14 +156,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
|||
return;
|
||||
}
|
||||
file.copySync(newMediaService.originalPath.path);
|
||||
if (!mounted) return;
|
||||
|
||||
await context.navPush(
|
||||
ShareImageEditorView(
|
||||
mediaFileService: newMediaService,
|
||||
sharedFromGallery: true,
|
||||
),
|
||||
);
|
||||
await _openEditor(newMediaService);
|
||||
});
|
||||
|
||||
if (HomeViewState.pendingSharedLink != null) {
|
||||
|
|
@ -269,17 +265,29 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
|||
|
||||
final draftMedia = await twonlyDB.mediaFilesDao.getDraftMediaFile();
|
||||
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;
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShareImageEditorView(
|
||||
mediaFileService: MediaFileService(draftMedia),
|
||||
_disableCameraTimer?.cancel();
|
||||
setState(() => _editorOpen = true);
|
||||
await _mainCameraController.closeCamera();
|
||||
if (!mounted) return;
|
||||
await context.navPush(
|
||||
ShareImageEditorView(
|
||||
mediaFileService: mediaFileService,
|
||||
sharedFromGallery: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (mounted) setState(() => _editorOpen = false);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -453,7 +461,9 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
|||
child: CameraPreviewControllerView(
|
||||
mainController: _mainCameraController,
|
||||
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:permission_handler/permission_handler.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
|
||||
import 'package:twonly/src/services/user.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||
|
||||
|
|
@ -30,6 +32,13 @@ class _NotificationViewState extends State<NotificationView> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleStoryNotifications() async {
|
||||
await UserService.update((u) {
|
||||
u.storyNotifications = !u.storyNotifications;
|
||||
});
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _resetTokens() async {
|
||||
setState(() {
|
||||
_isLoadingReset = true;
|
||||
|
|
@ -60,6 +69,15 @@ class _NotificationViewState extends State<NotificationView> {
|
|||
subtitle: Text(context.lang.settingsNotifyPermissionDesc),
|
||||
onTap: openAppSettings,
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.lang.settingsNotifyStories),
|
||||
subtitle: Text(context.lang.settingsNotifyStoriesSubtitle),
|
||||
onTap: _toggleStoryNotifications,
|
||||
trailing: Switch.adaptive(
|
||||
value: userService.currentUser.storyNotifications,
|
||||
onChanged: (_) => _toggleStoryNotifications(),
|
||||
),
|
||||
),
|
||||
if (_hasNotificationPermission == true)
|
||||
ListTile(
|
||||
title: Text(context.lang.settingsNotifyResetTitle),
|
||||
|
|
|
|||
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/my_button.element.dart';
|
||||
import 'package:twonly/src/visual/views/groups/group.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/profile/own_story_strip.comp.dart';
|
||||
|
||||
class ProfileView extends StatefulWidget {
|
||||
const ProfileView({super.key});
|
||||
|
|
@ -125,6 +126,7 @@ class _ProfileViewState extends State<ProfileView> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const OwnStoryStrip(),
|
||||
const Divider(),
|
||||
BetterListTile(
|
||||
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",
|
||||
"query": "SELECT message_id, sender_id AS \"sender_id!: i64\" FROM messages\n WHERE media_id = ? AND sender_id IS NOT NULL\n AND (opened_at IS NULL OR is_widget_media = 1)",
|
||||
"query": "SELECT message_id, sender_id AS \"sender_id!: i64\" FROM messages\n WHERE media_id = ? AND sender_id IS NOT NULL\n AND (opened_at IS NULL OR is_widget_media = 1 OR is_story = 1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
|
@ -34,5 +34,5 @@
|
|||
true
|
||||
]
|
||||
},
|
||||
"hash": "f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52"
|
||||
"hash": "649d1ed9bcc18ad5ea496f3c10992e31190deaea130850e4c286267d053158e7"
|
||||
}
|
||||
|
|
@ -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",
|
||||
"query": "\n INSERT INTO messages(\n group_id,\n message_id,\n sender_id,\n type,\n media_id,\n additional_message_data,\n quotes_message_id,\n is_widget_media,\n opened_at,\n opened_by_all,\n created_at\n ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?)\n ",
|
||||
"query": "\n INSERT INTO messages(\n group_id,\n message_id,\n sender_id,\n type,\n media_id,\n additional_message_data,\n quotes_message_id,\n is_widget_media,\n is_story,\n opened_at,\n opened_by_all,\n created_at\n ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
"Right": 11
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e"
|
||||
"hash": "c44fdd0c6ac77ef4b604dd0008f69db96edbcac9c3f3dac16d8c0945b5451d9e"
|
||||
}
|
||||
|
|
@ -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;
|
||||
mod reaction;
|
||||
pub mod recovery;
|
||||
pub(crate) mod story;
|
||||
mod text_message;
|
||||
mod typing_indicator;
|
||||
mod user_discovery;
|
||||
|
|
@ -495,7 +496,7 @@ pub async fn handle_decoded_server_message(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let mut sends_error_response = false;
|
||||
let mut withhold_delivery_receipt = false;
|
||||
let mut rebuild_session = false;
|
||||
|
||||
match message_type {
|
||||
|
|
@ -520,7 +521,7 @@ pub async fn handle_decoded_server_message(
|
|||
}
|
||||
// Never acknowledged: the message was not read, so the sender must
|
||||
// not be told it was delivered.
|
||||
sends_error_response = true;
|
||||
withhold_delivery_receipt = true;
|
||||
}
|
||||
Type::CiphertextV2 => {
|
||||
let decrypted = match resumed_plaintext {
|
||||
|
|
@ -577,7 +578,7 @@ pub async fn handle_decoded_server_message(
|
|||
"invalid decrypted client content: {error}"
|
||||
))
|
||||
})?;
|
||||
sends_error_response = process_encrypted_or_queue_error(
|
||||
withhold_delivery_receipt = process_encrypted_or_queue_error(
|
||||
ctx,
|
||||
&mut t,
|
||||
from_user_id,
|
||||
|
|
@ -585,7 +586,7 @@ pub async fn handle_decoded_server_message(
|
|||
content,
|
||||
)
|
||||
.await?
|
||||
.is_some();
|
||||
== Delivery::Withhold;
|
||||
}
|
||||
// The ratchet has already consumed this message, so it was
|
||||
// handled and the peer is only missing the answer. Falling
|
||||
|
|
@ -611,7 +612,7 @@ pub async fn handle_decoded_server_message(
|
|||
error_type as i32,
|
||||
)
|
||||
.await?;
|
||||
sends_error_response = true;
|
||||
withhold_delivery_receipt = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -627,7 +628,7 @@ pub async fn handle_decoded_server_message(
|
|||
Type::TestNotification => {}
|
||||
}
|
||||
|
||||
if is_encrypted_message & !sends_error_response {
|
||||
if is_encrypted_message & !withhold_delivery_receipt {
|
||||
queue_sender_delivery_receipt(&mut t, from_user_id, &message.receipt_id).await?;
|
||||
}
|
||||
|
||||
|
|
@ -720,6 +721,16 @@ async fn rebuild_session(ctx: &Arc<Context>, from_user_id: i64) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the sender learns that a handled message arrived.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Delivery {
|
||||
/// Queue the sender delivery receipt, which ends the sender's retries.
|
||||
Ack,
|
||||
/// Tell the sender nothing, either because an error response goes back
|
||||
/// instead or because the content is ignored outright.
|
||||
Withhold,
|
||||
}
|
||||
|
||||
/// Dispatches already-decrypted client-to-client content to its concrete
|
||||
/// feature module. Transport decoding and Signal decryption do not belong in
|
||||
/// this dispatcher.
|
||||
|
|
@ -729,7 +740,15 @@ pub(crate) async fn handle_encrypted(
|
|||
from_user_id: i64,
|
||||
receipt_id: &str,
|
||||
content: proto::EncryptedContent,
|
||||
) -> Result<()> {
|
||||
) -> Result<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();
|
||||
handle_encrypted_inner(ctx, t, from_user_id, receipt_id, content).await?;
|
||||
crate::services::notifications::record_incoming_event(
|
||||
|
|
@ -739,7 +758,8 @@ pub(crate) async fn handle_encrypted(
|
|||
receipt_id,
|
||||
¬ification_content,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
Ok(Delivery::Ack)
|
||||
}
|
||||
|
||||
async fn handle_encrypted_inner(
|
||||
|
|
@ -793,6 +813,10 @@ async fn handle_encrypted_inner(
|
|||
return media::handle_media_update(ctx, t, from_user_id, update).await;
|
||||
}
|
||||
|
||||
if let Some(item) = content.story {
|
||||
return story::handle_story(ctx, t, from_user_id, item).await;
|
||||
}
|
||||
|
||||
if let Some(error) = content.error_messages {
|
||||
return errors::handle_error_message(
|
||||
ctx,
|
||||
|
|
@ -925,7 +949,7 @@ async fn handle_encrypted_inner(
|
|||
}
|
||||
|
||||
if let Some(media) = content.media {
|
||||
return media::handle_media(ctx, t, from_user_id, &group_id, media).await;
|
||||
return media::handle_media(ctx, t, from_user_id, &group_id, media, false).await;
|
||||
}
|
||||
|
||||
if let Some(reaction) = content.reaction {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ pub(crate) async fn handle_media(
|
|||
from_user_id: i64,
|
||||
group_id: &str,
|
||||
media: encrypted_content::Media,
|
||||
is_story: bool,
|
||||
) -> Result<()> {
|
||||
let media_type = MediaType::try_from(media.r#type)?;
|
||||
let widget_only = media.widget_only == Some(true);
|
||||
|
|
@ -225,10 +226,11 @@ pub(crate) async fn handle_media(
|
|||
additional_message_data,
|
||||
quotes_message_id,
|
||||
is_widget_media,
|
||||
is_story,
|
||||
opened_at,
|
||||
opened_by_all,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
group_id,
|
||||
media.sender_message_id,
|
||||
|
|
@ -237,6 +239,7 @@ pub(crate) async fn handle_media(
|
|||
media.additional_message_data,
|
||||
media.quote_message_id,
|
||||
widget_only,
|
||||
is_story,
|
||||
opened_at,
|
||||
opened_by_all,
|
||||
timestamp,
|
||||
|
|
@ -244,9 +247,12 @@ pub(crate) async fn handle_media(
|
|||
.execute(&mut **t)
|
||||
.await?;
|
||||
|
||||
if !widget_only {
|
||||
// A story is not an exchange in the chat it is filed under: it neither
|
||||
// moves the chat up nor feeds its flames.
|
||||
if !widget_only && !is_story {
|
||||
Group::increase_last_message_exchange(t, group_id, timestamp).await?;
|
||||
Group::record_media_exchange(t, group_id, true, timestamp).await?;
|
||||
Group::record_text_or_media(t, group_id, timestamp).await?;
|
||||
}
|
||||
|
||||
spawn_media_download(ctx, media_id);
|
||||
|
|
@ -300,8 +306,19 @@ pub(crate) async fn handle_media_update(
|
|||
.execute(&mut **t)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
MediaUpdateType::DecryptionError => {
|
||||
// The upload state and the requester list are owned by the reupload
|
||||
// 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::Server;
|
||||
use crate::bridge::api::ServerResult;
|
||||
|
|
@ -167,16 +167,16 @@ pub(crate) async fn process_encrypted_or_queue_error(
|
|||
from_user_id: i64,
|
||||
receipt_id: &str,
|
||||
content: proto::EncryptedContent,
|
||||
) -> Result<Option<String>> {
|
||||
) -> Result<Delivery> {
|
||||
let group_id = content.group_id.clone();
|
||||
|
||||
match handle_encrypted(ctx, t, from_user_id, receipt_id, content).await {
|
||||
Ok(()) => Ok(None),
|
||||
Ok(delivery) => Ok(delivery),
|
||||
Err(error) => {
|
||||
let description = error.to_string();
|
||||
if description.contains("group join arrived before") {
|
||||
queue_retry_control(t, from_user_id, receipt_id).await?;
|
||||
return Ok(Some(receipt_id.to_owned()));
|
||||
return Ok(Delivery::Withhold);
|
||||
}
|
||||
|
||||
let error_type = if description.contains("not a member") {
|
||||
|
|
@ -221,7 +221,7 @@ pub(crate) async fn process_encrypted_or_queue_error(
|
|||
.execute(&mut **t)
|
||||
.await?;
|
||||
|
||||
Ok(Some(outgoing_receipt_id))
|
||||
Ok(Delivery::Withhold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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?;
|
||||
|
||||
Group::increase_last_message_exchange(t, group_id, timestamp).await?;
|
||||
Group::record_text_or_media(t, group_id, timestamp).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -72,6 +73,17 @@ pub(crate) async fn handle_message_update(
|
|||
.remove_files_if_deleted(t, &media.media_id, &media.media_type)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// A story taken down before it was seen should not stay announced.
|
||||
let cleared_at = crate::utils::current_time().timestamp();
|
||||
sqlx::query!(
|
||||
r#"UPDATE notification_outbox SET cleared_at = ?
|
||||
WHERE message_id = ? AND kind = 'story' AND cleared_at IS NULL"#,
|
||||
cleared_at,
|
||||
update.sender_message_id,
|
||||
)
|
||||
.execute(&mut **t)
|
||||
.await?;
|
||||
}
|
||||
Type::EditText => {
|
||||
Message::edit_text(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ pub(crate) fn content_type_kind(
|
|||
"MessageUpdate"
|
||||
} else if content.media.is_some() {
|
||||
"Media"
|
||||
} else if content.story.is_some() {
|
||||
"Story"
|
||||
} else if content.media_update.is_some() {
|
||||
"MediaUpdate"
|
||||
} else if content.contact_update.is_some() {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ message EncryptedContent {
|
|||
optional KeyVerificationProof key_verification_proof = 24;
|
||||
optional PasswordLessRecovery passwordless_recovery = 26;
|
||||
optional PasswordLessRecoveryHeartbeat passwordless_recovery_heartbeat = 27;
|
||||
optional Story story = 30;
|
||||
|
||||
message ErrorMessages {
|
||||
enum Type {
|
||||
|
|
@ -134,6 +135,17 @@ message EncryptedContent {
|
|||
optional bool widget_only = 12;
|
||||
}
|
||||
|
||||
// A story item. It carries no group ID: the receiver files it under its 1:1
|
||||
// chat with the sender, keeps it for 24 hours after `media.timestamp`, and
|
||||
// never shows it in that chat unless it is saved.
|
||||
message Story {
|
||||
Media media = 1;
|
||||
// Whether the sender woke the receiver for this item, which it does for
|
||||
// the first story in 24 hours only. The receiver announces the story only
|
||||
// when this is set, so a push and its notification never disagree.
|
||||
bool notify = 2;
|
||||
}
|
||||
|
||||
message MediaUpdate {
|
||||
enum Type {
|
||||
REOPENED = 0;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use crate::services::contacts::ContactService;
|
|||
use crate::services::media_upload::{MediaSizeReport, MediaUploadService};
|
||||
use crate::services::messages::MessageService;
|
||||
use crate::services::outbox_dispatch::OutboxDispatchService;
|
||||
use crate::services::stories::StoryAudience;
|
||||
use crate::user_config::UserConfig;
|
||||
use flutter_rust_bridge::frb;
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -223,16 +224,24 @@ impl RustApi {
|
|||
crate::services::location_metadata::prewarm(ctx)
|
||||
}
|
||||
|
||||
/// Creates one outgoing message per selected group and starts the upload.
|
||||
/// Creates one outgoing message per selected group, plus the story rows
|
||||
/// for `story_audience`, and starts the upload they all share.
|
||||
pub async fn send_media_to_groups(
|
||||
media_id: String,
|
||||
group_ids: Vec<String>,
|
||||
additional_message_data: Option<Vec<u8>>,
|
||||
widget_only: bool,
|
||||
story_audience: Option<StoryAudience>,
|
||||
) -> Result<()> {
|
||||
let ctx = Context::get_static()?;
|
||||
MediaUploadService::new(ctx)
|
||||
.insert_into_messages(media_id, group_ids, additional_message_data, widget_only)
|
||||
.insert_into_messages(
|
||||
media_id,
|
||||
group_ids,
|
||||
additional_message_data,
|
||||
widget_only,
|
||||
story_audience,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub mod api;
|
|||
pub mod callbacks;
|
||||
pub mod groups;
|
||||
pub mod logging;
|
||||
pub mod stories;
|
||||
pub mod user_config;
|
||||
pub mod webxdc;
|
||||
pub mod wrapper;
|
||||
|
|
|
|||
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 const APP_DATABASE_FILE: &str = "app_db.sqlite";
|
||||
pub const APP_SCHEMA_VERSION: i64 = 9;
|
||||
pub const APP_SCHEMA_VERSION: i64 = 11;
|
||||
|
||||
/// User-owned application tables in the current Rust schema. Rust-only outbox
|
||||
/// tables are deliberately absent because they are reconstructed locally.
|
||||
|
|
|
|||
|
|
@ -440,6 +440,28 @@ impl Group {
|
|||
Ok(is_member)
|
||||
}
|
||||
|
||||
/// Notes a text or media message in this chat, sent or received. This is
|
||||
/// what "these two still talk" is measured by; see
|
||||
/// `stories::exchanged_recently`.
|
||||
pub async fn record_text_or_media(
|
||||
tr: &mut Transaction<'_, Sqlite>,
|
||||
group_id: &str,
|
||||
timestamp: i64,
|
||||
) -> Result<()> {
|
||||
let now = current_unix_timestamp()?;
|
||||
let timestamp = timestamp.min(now.saturating_add(MAX_FUTURE_TIMESTAMP_SKEW_SECONDS));
|
||||
sqlx::query!(
|
||||
r#"UPDATE groups
|
||||
SET last_text_or_media_at = MAX(COALESCE(last_text_or_media_at, 0), ?)
|
||||
WHERE group_id = ?"#,
|
||||
timestamp,
|
||||
group_id,
|
||||
)
|
||||
.execute(&mut **tr)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn increase_last_message_exchange_to_now(
|
||||
tr: &mut Transaction<'_, Sqlite>,
|
||||
group_id: &str,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ pub enum TwonlyError {
|
|||
#[error("encrypted media is {bytes} bytes but the plan allows {limit}")]
|
||||
MediaTooLarge { bytes: i64, limit: i64 },
|
||||
|
||||
#[error("a media send reaches {count} recipients but at most {limit} are allowed")]
|
||||
TooManyRecipients { count: usize, limit: usize },
|
||||
|
||||
/// The group server refused a state update because it was written against
|
||||
/// a version it no longer holds. Somebody else changed the group first, so
|
||||
/// the update has to be applied again to the version that won.
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
|||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||
);
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1675944024;
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1336092087;
|
||||
|
||||
// Section: executor
|
||||
|
||||
|
|
@ -462,6 +462,43 @@ fn wire__crate__bridge__webxdc__delete_instance_impl(
|
|||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__bridge__stories__delete_story_item_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::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(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
|
|
@ -968,6 +1005,36 @@ fn wire__crate__bridge__groups__manage_admin_state_impl(
|
|||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__bridge__stories__max_media_recipients_impl(
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::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(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
|
|
@ -1080,6 +1147,41 @@ fn wire__crate__bridge__webxdc__prepare_bundle_impl(
|
|||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__bridge__stories__purge_expired_stories_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::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(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
|
|
@ -4198,6 +4300,8 @@ fn wire__crate__bridge__api__rust_api_send_media_to_groups_impl(
|
|||
let api_group_ids = <Vec<String>>::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_story_audience =
|
||||
<Option<crate::bridge::stories::StoryAudience>>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
|
||||
|
|
@ -4207,6 +4311,7 @@ fn wire__crate__bridge__api__rust_api_send_media_to_groups_impl(
|
|||
api_group_ids,
|
||||
api_additional_message_data,
|
||||
api_widget_only,
|
||||
api_story_audience,
|
||||
)
|
||||
.await?;
|
||||
Ok(output_ok)
|
||||
|
|
@ -6066,6 +6171,44 @@ fn wire__crate__bridge__webxdc__serve_impl(
|
|||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__bridge__stories__story_audience_chats_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::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(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
|
|
@ -6458,10 +6601,17 @@ fn wire__crate__bridge__logging__write_log_impl(
|
|||
|
||||
#[allow(clippy::unnecessary_literal_unwrap)]
|
||||
const _: fn() = || {
|
||||
{
|
||||
let FlameState = None::<crate::bridge::FlameState>.unwrap();
|
||||
let _: i64 = FlameState.counter;
|
||||
let _: bool = FlameState.is_expiring;
|
||||
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
|
||||
|
|
@ -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> {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
|
|
@ -7567,6 +7730,18 @@ impl SseDecode for crate::database::app::SqlValue {
|
|||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::bridge::stories::StoryAudience {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut var_all = <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 {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
|
|
@ -7678,6 +7853,7 @@ impl SseDecode for crate::user_config::UserConfig {
|
|||
let mut var_storeLocationInMemories = <bool>::sse_decode(deserializer);
|
||||
let mut var_autoStoreAllSendUnlimitedMediaFiles = <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_myBestFriendGroupId = <Option<String>>::sse_decode(deserializer);
|
||||
let mut var_signalLastSignedPreKeyUpdated =
|
||||
|
|
@ -7747,6 +7923,7 @@ impl SseDecode for crate::user_config::UserConfig {
|
|||
store_location_in_memories: var_storeLocationInMemories,
|
||||
auto_store_all_send_unlimited_media_files: var_autoStoreAllSendUnlimitedMediaFiles,
|
||||
typing_indicators: var_typingIndicators,
|
||||
story_notifications: var_storyNotifications,
|
||||
show_restore_flame: var_showRestoreFlame,
|
||||
my_best_friend_group_id: var_myBestFriendGroupId,
|
||||
signal_last_signed_pre_key_updated: var_signalLastSignedPreKeyUpdated,
|
||||
|
|
@ -7920,166 +8097,169 @@ fn pde_ffi_dispatcher_primary_impl(
|
|||
9 => wire__crate__bridge__webxdc__create_instance_impl(port, ptr, rust_vec_len, data_len),
|
||||
10 => wire__crate__bridge__groups__create_new_group_impl(port, ptr, rust_vec_len, data_len),
|
||||
11 => wire__crate__bridge__webxdc__delete_instance_impl(port, ptr, rust_vec_len, data_len),
|
||||
12 => wire__crate__bridge__groups__fetch_group_state_impl(port, ptr, rust_vec_len, data_len),
|
||||
13 => wire__crate__bridge__groups__fetch_group_states_for_unjoined_groups_impl(port, ptr, rust_vec_len, data_len),
|
||||
14 => wire__crate__bridge__groups__fetch_missing_group_public_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
15 => wire__crate__bridge__groups__flame_state_impl(port, ptr, rust_vec_len, data_len),
|
||||
16 => wire__crate__bridge__groups__flame_states_impl(port, ptr, rust_vec_len, data_len),
|
||||
17 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_change_exclusion_for_contact_impl(port, ptr, rust_vec_len, data_len),
|
||||
18 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_current_version_impl(port, ptr, rust_vec_len, data_len),
|
||||
19 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_verification_state_for_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
20 => wire__crate__bridge__callbacks__init_flutter_callbacks_impl(port, ptr, rust_vec_len, data_len),
|
||||
21 => wire__crate__bridge__initialize_twonly_flutter_impl(port, ptr, rust_vec_len, data_len),
|
||||
22 => wire__crate__bridge__initialize_twonly_standalone_impl(port, ptr, rust_vec_len, data_len),
|
||||
23 => wire__crate__bridge__webxdc__instance_impl(port, ptr, rust_vec_len, data_len),
|
||||
24 => wire__crate__bridge__groups__leave_group_impl(port, ptr, rust_vec_len, data_len),
|
||||
25 => wire__crate__bridge__logging__load_log_file_impl(port, ptr, rust_vec_len, data_len),
|
||||
26 => wire__crate__bridge__groups__manage_admin_state_impl(port, ptr, rust_vec_len, data_len),
|
||||
27 => wire__crate__bridge__webxdc__members_impl(port, ptr, rust_vec_len, data_len),
|
||||
28 => wire__crate__bridge__webxdc__one_time_instances_impl(port, ptr, rust_vec_len, data_len),
|
||||
29 => wire__crate__bridge__webxdc__prepare_bundle_impl(port, ptr, rust_vec_len, data_len),
|
||||
30 => wire__crate__bridge__logging__read_last_log_lines_impl(port, ptr, rust_vec_len, data_len),
|
||||
31 => wire__crate__bridge__webxdc__refresh_catalog_impl(port, ptr, rust_vec_len, data_len),
|
||||
32 => wire__crate__bridge__groups__remove_member_from_group_impl(port, ptr, rust_vec_len, data_len),
|
||||
33 => wire__crate__bridge__groups__restore_flames_impl(port, ptr, rust_vec_len, data_len),
|
||||
34 => wire__crate__bridge__api__rust_api_abandon_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
35 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
36 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len),
|
||||
38 => wire__crate__bridge__api__rust_api_authentication_headers_impl(port, ptr, rust_vec_len, data_len),
|
||||
40 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len),
|
||||
41 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len),
|
||||
42 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
|
||||
43 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len),
|
||||
44 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len),
|
||||
45 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len),
|
||||
46 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len),
|
||||
47 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len),
|
||||
48 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len),
|
||||
49 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len),
|
||||
50 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len),
|
||||
52 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len),
|
||||
53 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len),
|
||||
54 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
55 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len),
|
||||
56 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len),
|
||||
57 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
58 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
59 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len),
|
||||
60 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len),
|
||||
61 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len),
|
||||
62 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len),
|
||||
63 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len),
|
||||
64 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len),
|
||||
65 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len),
|
||||
66 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len),
|
||||
67 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len),
|
||||
68 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
|
||||
69 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
70 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len),
|
||||
71 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len),
|
||||
72 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len),
|
||||
73 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
|
||||
74 => wire__crate__bridge__api__rust_api_initialize_memory_location_impl(port, ptr, rust_vec_len, data_len),
|
||||
75 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
|
||||
76 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
77 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
|
||||
78 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
|
||||
79 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
|
||||
80 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
|
||||
81 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
|
||||
82 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
|
||||
83 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
|
||||
84 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
|
||||
85 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
|
||||
86 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
87 => wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(port, ptr, rust_vec_len, data_len),
|
||||
88 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
|
||||
89 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
90 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len),
|
||||
91 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
|
||||
92 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len),
|
||||
93 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
|
||||
94 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
|
||||
95 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
|
||||
96 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
97 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
|
||||
98 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
99 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
|
||||
100 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
|
||||
101 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
|
||||
102 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
|
||||
103 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
|
||||
104 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
|
||||
105 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
106 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
|
||||
107 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
|
||||
108 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
|
||||
109 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
|
||||
110 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
|
||||
111 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
|
||||
112 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
|
||||
113 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
|
||||
114 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
|
||||
115 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
|
||||
116 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len),
|
||||
117 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
|
||||
118 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
|
||||
119 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
|
||||
120 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len),
|
||||
121 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
|
||||
122 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
123 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
|
||||
124 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len),
|
||||
125 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
|
||||
126 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
127 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len),
|
||||
128 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
|
||||
129 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
130 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
131 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
|
||||
132 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
|
||||
133 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
|
||||
134 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
|
||||
135 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
|
||||
136 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
|
||||
137 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
|
||||
138 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
|
||||
139 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
140 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
141 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
|
||||
142 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
143 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
|
||||
144 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
145 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
146 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
147 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
|
||||
148 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
149 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
|
||||
150 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
|
||||
151 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
|
||||
152 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
|
||||
153 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
154 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
|
||||
155 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
|
||||
156 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
|
||||
157 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
|
||||
158 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
159 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len),
|
||||
160 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
161 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
|
||||
162 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len),
|
||||
163 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
|
||||
164 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
|
||||
165 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
|
||||
166 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
|
||||
167 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
|
||||
168 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
|
||||
169 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
|
||||
171 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
|
||||
172 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
|
||||
173 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
|
||||
174 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
|
||||
175 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
|
||||
12 => wire__crate__bridge__stories__delete_story_item_impl(port, ptr, rust_vec_len, data_len),
|
||||
13 => wire__crate__bridge__groups__fetch_group_state_impl(port, ptr, rust_vec_len, data_len),
|
||||
14 => wire__crate__bridge__groups__fetch_group_states_for_unjoined_groups_impl(port, ptr, rust_vec_len, data_len),
|
||||
15 => wire__crate__bridge__groups__fetch_missing_group_public_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
16 => wire__crate__bridge__groups__flame_state_impl(port, ptr, rust_vec_len, data_len),
|
||||
17 => wire__crate__bridge__groups__flame_states_impl(port, ptr, rust_vec_len, data_len),
|
||||
18 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_change_exclusion_for_contact_impl(port, ptr, rust_vec_len, data_len),
|
||||
19 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_current_version_impl(port, ptr, rust_vec_len, data_len),
|
||||
20 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_verification_state_for_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
21 => wire__crate__bridge__callbacks__init_flutter_callbacks_impl(port, ptr, rust_vec_len, data_len),
|
||||
22 => wire__crate__bridge__initialize_twonly_flutter_impl(port, ptr, rust_vec_len, data_len),
|
||||
23 => wire__crate__bridge__initialize_twonly_standalone_impl(port, ptr, rust_vec_len, data_len),
|
||||
24 => wire__crate__bridge__webxdc__instance_impl(port, ptr, rust_vec_len, data_len),
|
||||
25 => wire__crate__bridge__groups__leave_group_impl(port, ptr, rust_vec_len, data_len),
|
||||
26 => wire__crate__bridge__logging__load_log_file_impl(port, ptr, rust_vec_len, data_len),
|
||||
27 => wire__crate__bridge__groups__manage_admin_state_impl(port, ptr, rust_vec_len, data_len),
|
||||
29 => wire__crate__bridge__webxdc__members_impl(port, ptr, rust_vec_len, data_len),
|
||||
30 => wire__crate__bridge__webxdc__one_time_instances_impl(port, ptr, rust_vec_len, data_len),
|
||||
31 => wire__crate__bridge__webxdc__prepare_bundle_impl(port, ptr, rust_vec_len, data_len),
|
||||
32 => wire__crate__bridge__stories__purge_expired_stories_impl(port, ptr, rust_vec_len, data_len),
|
||||
33 => wire__crate__bridge__logging__read_last_log_lines_impl(port, ptr, rust_vec_len, data_len),
|
||||
34 => wire__crate__bridge__webxdc__refresh_catalog_impl(port, ptr, rust_vec_len, data_len),
|
||||
35 => wire__crate__bridge__groups__remove_member_from_group_impl(port, ptr, rust_vec_len, data_len),
|
||||
36 => wire__crate__bridge__groups__restore_flames_impl(port, ptr, rust_vec_len, data_len),
|
||||
37 => wire__crate__bridge__api__rust_api_abandon_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
38 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
39 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len),
|
||||
41 => wire__crate__bridge__api__rust_api_authentication_headers_impl(port, ptr, rust_vec_len, data_len),
|
||||
43 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len),
|
||||
44 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len),
|
||||
45 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
|
||||
46 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len),
|
||||
47 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len),
|
||||
48 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len),
|
||||
49 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len),
|
||||
50 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len),
|
||||
51 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len),
|
||||
52 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len),
|
||||
53 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len),
|
||||
55 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len),
|
||||
56 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len),
|
||||
57 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
58 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len),
|
||||
59 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len),
|
||||
60 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
61 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
62 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len),
|
||||
63 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len),
|
||||
64 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len),
|
||||
65 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len),
|
||||
66 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len),
|
||||
67 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len),
|
||||
68 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len),
|
||||
69 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len),
|
||||
70 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len),
|
||||
71 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
|
||||
72 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
73 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len),
|
||||
74 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len),
|
||||
75 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len),
|
||||
76 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
|
||||
77 => wire__crate__bridge__api__rust_api_initialize_memory_location_impl(port, ptr, rust_vec_len, data_len),
|
||||
78 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
|
||||
79 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
80 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
|
||||
81 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
|
||||
82 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
|
||||
83 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
|
||||
84 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
|
||||
85 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
|
||||
86 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
|
||||
87 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
|
||||
88 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
|
||||
89 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
90 => wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(port, ptr, rust_vec_len, data_len),
|
||||
91 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
|
||||
92 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
93 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len),
|
||||
94 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
|
||||
95 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len),
|
||||
96 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
|
||||
97 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
|
||||
98 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
|
||||
99 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
100 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
|
||||
101 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
|
||||
102 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
|
||||
103 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
|
||||
104 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
|
||||
105 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
|
||||
106 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
|
||||
107 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
|
||||
108 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
109 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
|
||||
110 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
|
||||
111 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
|
||||
112 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
|
||||
113 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
|
||||
114 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
|
||||
115 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
|
||||
116 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
|
||||
117 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
|
||||
118 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
|
||||
119 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len),
|
||||
120 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
|
||||
121 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
|
||||
122 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
|
||||
123 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len),
|
||||
124 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
|
||||
125 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
|
||||
126 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
|
||||
127 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len),
|
||||
128 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
|
||||
129 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
130 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len),
|
||||
131 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
|
||||
132 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
133 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
134 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
|
||||
135 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
|
||||
136 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
|
||||
137 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
|
||||
138 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
|
||||
139 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
|
||||
140 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
|
||||
141 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
|
||||
142 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
143 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
144 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
|
||||
145 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
146 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
|
||||
147 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
|
||||
148 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
149 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
150 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
|
||||
151 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
152 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
|
||||
153 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
|
||||
154 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
|
||||
155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
|
||||
156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
|
||||
157 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
|
||||
158 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
|
||||
159 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
|
||||
160 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
|
||||
161 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
162 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len),
|
||||
163 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
|
||||
164 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
|
||||
165 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len),
|
||||
166 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
|
||||
167 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
|
||||
168 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
|
||||
169 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
|
||||
170 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len),
|
||||
171 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
|
||||
172 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
|
||||
173 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
|
||||
175 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
|
||||
176 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
|
||||
177 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
|
||||
178 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
|
||||
179 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
|
@ -8092,17 +8272,18 @@ fn pde_ffi_dispatcher_sync_impl(
|
|||
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
||||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||
match func_id {
|
||||
37 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len),
|
||||
39 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len),
|
||||
51 => {
|
||||
28 => wire__crate__bridge__stories__max_media_recipients_impl(ptr, rust_vec_len, data_len),
|
||||
40 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len),
|
||||
42 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len),
|
||||
54 => {
|
||||
wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len)
|
||||
}
|
||||
170 => wire__crate__bridge__user_config__user_config_api_clone_impl(
|
||||
174 => wire__crate__bridge__user_config__user_config_api_clone_impl(
|
||||
ptr,
|
||||
rust_vec_len,
|
||||
data_len,
|
||||
),
|
||||
176 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
|
||||
180 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
|
@ -8877,6 +9058,27 @@ impl flutter_rust_bridge::IntoIntoDart<crate::database::app::SqlValue>
|
|||
}
|
||||
}
|
||||
// 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 {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
|
|
@ -8965,6 +9167,7 @@ impl flutter_rust_bridge::IntoDart for crate::user_config::UserConfig {
|
|||
.into_into_dart()
|
||||
.into_dart(),
|
||||
self.typing_indicators.into_into_dart().into_dart(),
|
||||
self.story_notifications.into_into_dart().into_dart(),
|
||||
self.show_restore_flame.into_into_dart().into_dart(),
|
||||
self.my_best_friend_group_id.into_into_dart().into_dart(),
|
||||
self.signal_last_signed_pre_key_updated
|
||||
|
|
@ -9797,6 +10000,16 @@ impl SseEncode for Option<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> {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
|
|
@ -10001,6 +10214,14 @@ impl SseEncode for crate::database::app::SqlValue {
|
|||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::bridge::stories::StoryAudience {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<bool>::sse_encode(self.all, serializer);
|
||||
<Vec<i64>>::sse_encode(self.contact_group_ids, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::user_config::ThemeMode {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
|
|
@ -10119,6 +10340,7 @@ impl SseEncode for crate::user_config::UserConfig {
|
|||
<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.typing_indicators, serializer);
|
||||
<bool>::sse_encode(self.story_notifications, serializer);
|
||||
<bool>::sse_encode(self.show_restore_flame, serializer);
|
||||
<Option<String>>::sse_encode(self.my_best_friend_group_id, serializer);
|
||||
<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?;
|
||||
}
|
||||
Job::Flush => {
|
||||
// First, so nothing below spends effort on an expired story.
|
||||
if let Err(error) = crate::services::stories::purge_expired(&ctx).await {
|
||||
tracing::warn!(%error, "could not purge expired stories");
|
||||
}
|
||||
if let Err(error) = MediaUploadService::new(&ctx).finish_started_uploads().await {
|
||||
tracing::warn!(%error, "could not finish started media uploads");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::context::Context;
|
|||
use crate::database::app::{tables::MediaFile, AppDatabase};
|
||||
use crate::error::{Result, TwonlyError};
|
||||
use crate::native::transfer;
|
||||
use crate::services::stories::{STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS, STORY_LIFETIME_SECONDS};
|
||||
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
use prost::Message;
|
||||
|
|
@ -39,6 +40,8 @@ const REFILL_THRESHOLD_KEY: &str = "direct_media_refill_threshold";
|
|||
/// durable jobs.
|
||||
const API_NAMESPACE_KEY: &str = "direct_media_api_namespace";
|
||||
const WATCH_FIRST_DELAY: Duration = Duration::from_secs(2);
|
||||
/// The server accepts at most this many recipients for one uploaded attachment.
|
||||
pub(crate) const MAX_ATTACHMENT_DISPATCHES: usize = 300;
|
||||
const WATCH_MAX_DELAY: Duration = Duration::from_secs(120);
|
||||
|
||||
static WATCHING: AtomicBool = AtomicBool::new(false);
|
||||
|
|
@ -159,6 +162,7 @@ struct MessageRow {
|
|||
quotes_message_id: Option<String>,
|
||||
additional_message_data: Option<Vec<u8>>,
|
||||
is_widget_media: i64,
|
||||
is_story: i64,
|
||||
}
|
||||
|
||||
#[derive(FromRow)]
|
||||
|
|
@ -953,14 +957,22 @@ impl DirectMediaUploadService {
|
|||
.execute(&database.pool)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let messages = sqlx::query_as::<_, MessageRow>(
|
||||
r#"SELECT group_id, message_id, created_at, quotes_message_id,
|
||||
additional_message_data, is_widget_media
|
||||
additional_message_data, is_widget_media, is_story
|
||||
FROM messages WHERE media_id = ?"#,
|
||||
)
|
||||
.bind(media_id)
|
||||
.fetch_all(&database.pool)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
// An upload that took longer than a story lives would deliver an item
|
||||
// its recipients ignore on arrival.
|
||||
.filter(|message| {
|
||||
message.is_story == 0 || message.created_at + STORY_LIFETIME_SECONDS > now
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if messages.is_empty() {
|
||||
return Err(TwonlyError::Generic(
|
||||
"media has no recipient messages".into(),
|
||||
|
|
@ -988,7 +1000,38 @@ impl DirectMediaUploadService {
|
|||
for recipient in recipients {
|
||||
let mut download_token = vec![0_u8; 32];
|
||||
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()),
|
||||
media: Some(encrypted_content::Media {
|
||||
sender_message_id: message.message_id.clone(),
|
||||
|
|
@ -1005,6 +1048,7 @@ impl DirectMediaUploadService {
|
|||
widget_only: Some(message.is_widget_media != 0),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
let encrypted_body = send_c2c_message_to_contact()
|
||||
.ctx(&self.ctx)
|
||||
|
|
@ -1023,13 +1067,14 @@ impl DirectMediaUploadService {
|
|||
encrypted_body,
|
||||
// Widget media arrives already opened and raises no
|
||||
// notification, so a push for it only buys the recipient
|
||||
// an alert with nothing behind it.
|
||||
push_data: (message.is_widget_media == 0).then(|| vec![1]),
|
||||
// an alert with nothing behind it. A story wakes only for
|
||||
// the first item in a day, see `story_wakes_recipient`.
|
||||
push_data: wake.then(|| vec![1]),
|
||||
download_token,
|
||||
});
|
||||
}
|
||||
}
|
||||
if dispatches.is_empty() || dispatches.len() > 300 {
|
||||
if dispatches.is_empty() || dispatches.len() > MAX_ATTACHMENT_DISPATCHES {
|
||||
return Err(TwonlyError::Generic(format!(
|
||||
"direct-media fan-out has {} dispatches",
|
||||
dispatches.len()
|
||||
|
|
@ -1046,6 +1091,64 @@ impl DirectMediaUploadService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether a story row's recipient is woken for it. Stories wake only for the
|
||||
/// first item in a day, so a burst of them is one alert rather than a stream:
|
||||
///
|
||||
/// - an older own story row in the same chat that has not expired yet means
|
||||
/// this is not the first. Ties on the timestamp are broken by message id, so
|
||||
/// two items prepared at once cannot each defer to the other.
|
||||
/// - a direct copy of the same media already wakes the recipient.
|
||||
/// - a row the server or recipient already acknowledged is being prepared
|
||||
/// again for a reupload, and was announced the first time.
|
||||
/// - the two have not sent each other a text or media message in two weeks;
|
||||
/// the recipient would not announce it then, so waking them is pointless.
|
||||
///
|
||||
/// The answer also travels inside the story, so the receiver announces
|
||||
/// exactly the items it was woken for.
|
||||
async fn story_wakes_recipient(
|
||||
database: &AppDatabase,
|
||||
message: &MessageRow,
|
||||
media_id: &str,
|
||||
contact_id: i64,
|
||||
now: i64,
|
||||
) -> Result<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 {
|
||||
match media_type {
|
||||
"video" => "mp4",
|
||||
|
|
@ -1090,6 +1193,141 @@ fn write_multipart_body(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::services::media_upload::MediaUploadService;
|
||||
use crate::user_config::UserConfig;
|
||||
use libsignal_protocol::IdentityKeyPair;
|
||||
use rand::SeedableRng;
|
||||
|
||||
async fn upload_test_context(root: &Path, user_id: i64) -> anyhow::Result<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]
|
||||
fn cancelled_watcher_releases_process_claim() {
|
||||
|
|
@ -1399,4 +1637,102 @@ mod tests {
|
|||
// out makes the server abandon the transfer as `not_in_client_cache`.
|
||||
assert_eq!(known, vec!["slot-cached", "slot-reserved"]);
|
||||
}
|
||||
|
||||
fn story_row(message_id: &str, created_at: i64) -> MessageRow {
|
||||
MessageRow {
|
||||
group_id: "d".into(),
|
||||
message_id: message_id.into(),
|
||||
created_at,
|
||||
quotes_message_id: None,
|
||||
additional_message_data: None,
|
||||
is_widget_media: 0,
|
||||
is_story: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_the_first_story_of_a_day_wakes_its_recipient() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let database_path = directory.path().join("app.sqlite");
|
||||
let database = AppDatabase::new(database_path.to_str().unwrap(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
database.run_migrations().await.unwrap();
|
||||
let now = 1_000_000_000;
|
||||
let expired = now - STORY_LIFETIME_SECONDS - 60;
|
||||
for statement in [
|
||||
"INSERT INTO contacts(user_id, username, accepted) VALUES (7, 'anna', 1)".to_owned(),
|
||||
format!("INSERT INTO groups(group_id, group_name, is_direct_chat, last_text_or_media_at) VALUES ('d', 'Anna', 1, {}), ('trip', 'Trip', 0, NULL)", now - 3600),
|
||||
"INSERT INTO group_members(group_id, contact_id) VALUES ('d', 7), ('trip', 7)".to_owned(),
|
||||
"INSERT INTO media_files(media_id, type) VALUES ('old', 'image'), ('first', 'image'), ('second', 'image'), ('twin', 'image')".to_owned(),
|
||||
format!("INSERT INTO messages(message_id, group_id, type, media_id, is_story, created_at) VALUES ('old', 'd', 'media', 'old', 1, {expired})"),
|
||||
format!("INSERT INTO messages(message_id, group_id, type, media_id, is_story, created_at) VALUES ('first', 'd', 'media', 'first', 1, {})", now - 120),
|
||||
format!("INSERT INTO messages(message_id, group_id, type, media_id, is_story, created_at) VALUES ('second', 'd', 'media', 'second', 1, {now})"),
|
||||
format!("INSERT INTO messages(message_id, group_id, type, media_id, is_story, created_at) VALUES ('twin', 'd', 'media', 'twin', 1, {now})"),
|
||||
] {
|
||||
sqlx::query(sqlx::AssertSqlSafe(statement))
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let wakes = |row: MessageRow, media_id: &'static str| {
|
||||
let database = &database;
|
||||
async move {
|
||||
story_wakes_recipient(database, &row, media_id, 7, now)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
// An expired story does not count against the first one of a new day.
|
||||
assert!(wakes(story_row("first", now - 120), "first").await);
|
||||
assert!(!wakes(story_row("second", now), "second").await);
|
||||
// Two items posted in the same second: exactly one of them is first
|
||||
// once the older one is gone.
|
||||
sqlx::query("DELETE FROM messages WHERE message_id = 'first'")
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(wakes(story_row("second", now), "second").await);
|
||||
assert!(!wakes(story_row("twin", now), "twin").await);
|
||||
|
||||
// A direct copy of the same media already wakes the recipient.
|
||||
sqlx::query(
|
||||
"INSERT INTO messages(message_id, group_id, type, media_id) VALUES ('direct', 'trip', 'media', 'second')",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!wakes(story_row("second", now), "second").await);
|
||||
sqlx::query("DELETE FROM messages WHERE message_id = 'direct'")
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Preparing an item again after it reached the server does not
|
||||
// announce it a second time.
|
||||
sqlx::query(
|
||||
"INSERT INTO message_actions(message_id, contact_id, type) VALUES ('second', 7, 'ackByServerAt')",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!wakes(story_row("second", now), "second").await);
|
||||
|
||||
// Two weeks without a text or media message between them: the
|
||||
// recipient would not announce it, so they are not woken for it.
|
||||
sqlx::query("DELETE FROM message_actions")
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(wakes(story_row("second", now), "second").await);
|
||||
sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||
"UPDATE groups SET last_text_or_media_at = {} WHERE group_id = 'd'",
|
||||
now - STORY_ANNOUNCE_RECENT_EXCHANGE_SECONDS - 60
|
||||
)))
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!wakes(story_row("second", now), "second").await);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,12 @@ use crate::error::{Result, TwonlyError};
|
|||
use crate::native::gallery;
|
||||
use crate::native::prepare;
|
||||
use crate::native::video;
|
||||
use crate::services::direct_media_upload::DirectMediaUploadService;
|
||||
use crate::services::direct_media_upload::{DirectMediaUploadService, MAX_ATTACHMENT_DISPATCHES};
|
||||
use crate::services::media_codec;
|
||||
use crate::services::media_exif;
|
||||
use crate::services::mediafiles::MediaFileService;
|
||||
use crate::services::messages::MessageService;
|
||||
use crate::services::stories::{self, StoryAudience};
|
||||
use crate::user_config::UserConfig;
|
||||
use crate::utils::new_uuid_v7;
|
||||
use prost::Message as _;
|
||||
|
|
@ -161,19 +162,68 @@ impl MediaUploadService {
|
|||
Ok(media_id)
|
||||
}
|
||||
|
||||
/// Turns an edited media file into one outgoing message per selected group
|
||||
/// and hands the send to the background uploader.
|
||||
/// Turns an edited media file into one outgoing message per selected group,
|
||||
/// plus one hidden story row per contact `story` reaches, and hands the
|
||||
/// send to the background uploader. Everything shares one upload.
|
||||
pub async fn insert_into_messages(
|
||||
&self,
|
||||
media_id: String,
|
||||
group_ids: Vec<String>,
|
||||
additional_message_data: Option<Vec<u8>>,
|
||||
widget_only: bool,
|
||||
story: Option<StoryAudience>,
|
||||
) -> Result<()> {
|
||||
let database = self.ctx.app_db.read().await.clone();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
let mut transaction = database.pool.begin().await?;
|
||||
let story_recipients = match &story {
|
||||
Some(audience) => {
|
||||
let (media_type, requires_authentication) = sqlx::query_as::<_, (String, i64)>(
|
||||
"SELECT type, requires_authentication FROM media_files WHERE media_id = ?",
|
||||
)
|
||||
.bind(&media_id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?
|
||||
.ok_or_else(|| TwonlyError::Generic(format!("media {media_id} does not exist")))?;
|
||||
// A story replays for everybody until it expires, which is the
|
||||
// opposite of what a twonly-protected send promises.
|
||||
if widget_only
|
||||
|| requires_authentication != 0
|
||||
|| !matches!(media_type.as_str(), "image" | "video")
|
||||
{
|
||||
return Err(TwonlyError::Generic(
|
||||
"stories take an unprotected image or video".into(),
|
||||
));
|
||||
}
|
||||
stories::resolve_audience(&mut transaction, audience).await?
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
// The server takes a bounded number of recipients per upload. Refusing
|
||||
// here keeps the media from being retried against that limit forever.
|
||||
let mut dispatches = story_recipients.len();
|
||||
for group_id in &group_ids {
|
||||
dispatches += sqlx::query_scalar::<_, i64>(
|
||||
r#"SELECT COUNT(*) FROM group_members gm
|
||||
JOIN contacts c ON c.user_id = gm.contact_id
|
||||
WHERE gm.group_id = ? AND c.account_deleted = 0
|
||||
AND (gm.member_state IS NULL OR gm.member_state != 'leftGroup')"#,
|
||||
)
|
||||
.bind(group_id)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await? as usize;
|
||||
}
|
||||
if dispatches == 0 {
|
||||
return Err(TwonlyError::Generic("media send has no recipients".into()));
|
||||
}
|
||||
if dispatches > MAX_ATTACHMENT_DISPATCHES {
|
||||
return Err(TwonlyError::TooManyRecipients {
|
||||
count: dispatches,
|
||||
limit: MAX_ATTACHMENT_DISPATCHES,
|
||||
});
|
||||
}
|
||||
|
||||
if widget_only {
|
||||
let media = sqlx::query_as::<_, (String, Option<i64>)>(
|
||||
"SELECT type, display_limit_in_milliseconds FROM media_files WHERE media_id = ?",
|
||||
|
|
@ -253,8 +303,27 @@ impl MediaUploadService {
|
|||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
Group::record_media_exchange(&mut transaction, group_id, false, now).await?;
|
||||
Group::record_text_or_media(&mut transaction, group_id, now).await?;
|
||||
}
|
||||
}
|
||||
// A story row neither lifts its chat nor feeds its flames: it is not
|
||||
// part of the conversation until somebody saves it.
|
||||
for contact_id in story_recipients {
|
||||
let group_id = stories::direct_chat(&self.ctx, &mut transaction, contact_id).await?;
|
||||
sqlx::query(
|
||||
r#"INSERT INTO messages
|
||||
(group_id, message_id, type, media_id, additional_message_data,
|
||||
is_story, created_at)
|
||||
VALUES (?, ?, 'media', ?, ?, 1, ?)"#,
|
||||
)
|
||||
.bind(&group_id)
|
||||
.bind(new_uuid_v7())
|
||||
.bind(&media_id)
|
||||
.bind(additional_message_data.as_deref())
|
||||
.bind(now)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
drop(database);
|
||||
|
||||
|
|
@ -399,6 +468,9 @@ impl MediaUploadService {
|
|||
if let Err(error) = self.record_plaintext_size(media_id, &temp_path).await {
|
||||
tracing::warn!(media_id, %error, "could not record the media size");
|
||||
}
|
||||
if let Err(error) = self.create_story_thumbnail(media_id).await {
|
||||
tracing::warn!(media_id, %error, "could not create the story thumbnail");
|
||||
}
|
||||
|
||||
// Auto-storing has to happen before the plaintext is consumed, and only
|
||||
// for media the recipient could have kept anyway.
|
||||
|
|
@ -613,6 +685,20 @@ impl MediaUploadService {
|
|||
}
|
||||
};
|
||||
|
||||
// The recipient ignores a story once it expired, so resending one only
|
||||
// costs traffic until the story purge takes the receipt away.
|
||||
let expired_story = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT EXISTS(SELECT 1 FROM messages WHERE message_id = ? AND is_story = 1 AND created_at <= ?)",
|
||||
)
|
||||
.bind(&message_id)
|
||||
.bind(now - stories::STORY_LIFETIME_SECONDS)
|
||||
.fetch_one(&database.pool)
|
||||
.await?
|
||||
!= 0;
|
||||
if expired_story {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Nothing can be delivered to a contact who has not accepted yet.
|
||||
if receipt.mark_for_retry_after_accepted.is_some() {
|
||||
let accepted = sqlx::query_scalar::<_, Option<i64>>(
|
||||
|
|
@ -972,11 +1058,15 @@ impl MediaUploadService {
|
|||
-- still reopen their own copy.
|
||||
AND (m.sender_id IS NULL OR g.is_direct_chat = 0)
|
||||
)
|
||||
-- A story replays until it expires, and a viewer saving it
|
||||
-- makes the sender store its own copy from these bytes.
|
||||
OR (m.is_story = 1 AND m.created_at > ?)
|
||||
)"#,
|
||||
)
|
||||
.bind(media_id)
|
||||
.bind(now - 3 * 60)
|
||||
.bind(now - 2 * 24 * 60 * 60)
|
||||
.bind(now - stories::STORY_LIFETIME_SECONDS)
|
||||
.fetch_one(&database.pool)
|
||||
.await?;
|
||||
Ok(keepers == 0)
|
||||
|
|
@ -1281,28 +1371,48 @@ impl MediaUploadService {
|
|||
async fn create_thumbnail(&self, media: &MediaRow) -> Result<()> {
|
||||
let files = MediaFileService::new(&self.ctx);
|
||||
let stored = files.stored_path(&media.media_id, &media.media_type);
|
||||
let thumbnail = files.thumbnail_path(&media.media_id);
|
||||
if media.media_type == "audio" || !stored.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let media_id = media.media_id.clone();
|
||||
let is_video = media.media_type == "video";
|
||||
if let Err(error) = blocking(move || {
|
||||
if is_video {
|
||||
// Only the frame grab needs the platform's video decoder; the
|
||||
// scaling and encoding is the same code stills use.
|
||||
let frame = thumbnail.with_extension("frame.png");
|
||||
video::extract_frame(&stored, &frame)?;
|
||||
let result = media_codec::create_image_thumbnail(&frame, &thumbnail);
|
||||
let _ = std::fs::remove_file(&frame);
|
||||
result
|
||||
} else {
|
||||
media_codec::create_image_thumbnail(&stored, &thumbnail)
|
||||
render_thumbnail(
|
||||
&media.media_id,
|
||||
stored,
|
||||
files.thumbnail_path(&media.media_id),
|
||||
media.media_type == "video",
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(media_id, %error, "could not create a thumbnail");
|
||||
|
||||
/// Renders the frame a reply to a video story is shown with. Stored media
|
||||
/// gets its thumbnail from the stored file, but a story is replied to long
|
||||
/// before anybody stores it, so this one comes from the plaintext.
|
||||
pub(crate) async fn create_story_thumbnail(&self, media_id: &str) -> Result<()> {
|
||||
let Some(media) = self.load(media_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
if media.media_type != "video" {
|
||||
return Ok(());
|
||||
}
|
||||
let database = self.ctx.app_db.read().await.clone();
|
||||
let is_story = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT EXISTS(SELECT 1 FROM messages WHERE media_id = ? AND is_story = 1)",
|
||||
)
|
||||
.bind(media_id)
|
||||
.fetch_one(&database.pool)
|
||||
.await?
|
||||
!= 0;
|
||||
let files = MediaFileService::new(&self.ctx);
|
||||
let source = files.temp_path(media_id, &media.media_type);
|
||||
let thumbnail = files.thumbnail_path(media_id);
|
||||
if !is_story || !source.exists() || thumbnail.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if render_thumbnail(media_id, source, thumbnail, true).await {
|
||||
sqlx::query("UPDATE media_files SET has_thumbnail = 1 WHERE media_id = ?")
|
||||
.bind(media_id)
|
||||
.execute(&database.pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1501,6 +1611,34 @@ where
|
|||
.map_err(|error| TwonlyError::Generic(error.to_string()))?
|
||||
}
|
||||
|
||||
/// Writes a webp thumbnail of `source` to `thumbnail`. A failure only costs
|
||||
/// the preview, so it is logged and reported rather than returned.
|
||||
async fn render_thumbnail(
|
||||
media_id: &str,
|
||||
source: std::path::PathBuf,
|
||||
thumbnail: std::path::PathBuf,
|
||||
is_video: bool,
|
||||
) -> bool {
|
||||
let result = blocking(move || {
|
||||
if is_video {
|
||||
// Only the frame grab needs the platform's video decoder; the
|
||||
// scaling and encoding is the same code stills use.
|
||||
let frame = thumbnail.with_extension("frame.png");
|
||||
video::extract_frame(&source, &frame)?;
|
||||
let result = media_codec::create_image_thumbnail(&frame, &thumbnail);
|
||||
let _ = std::fs::remove_file(&frame);
|
||||
result
|
||||
} else {
|
||||
media_codec::create_image_thumbnail(&source, &thumbnail)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = &result {
|
||||
tracing::warn!(media_id, %error, "could not create a thumbnail");
|
||||
}
|
||||
result.is_ok()
|
||||
}
|
||||
|
||||
fn remove_file(path: &Path) {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => {}
|
||||
|
|
@ -1531,6 +1669,42 @@ fn parse_reupload_requested_by(value: Option<&str>) -> Vec<i64> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_a_story_send_with_no_resolved_recipients() -> anyhow::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let data_dir = temp.path().join("data");
|
||||
std::fs::create_dir_all(data_dir.join("keyvalue"))?;
|
||||
std::fs::write(
|
||||
data_dir.join("keyvalue/user.json"),
|
||||
serde_json::to_vec(&UserConfig::default())?,
|
||||
)?;
|
||||
let ctx = Context::init_for_testing(temp.path().join("database"), data_dir).await?;
|
||||
let service = MediaUploadService::new(&ctx);
|
||||
let media_id = service.initialize("image".into(), None, false).await?;
|
||||
|
||||
let error = service
|
||||
.insert_into_messages(
|
||||
media_id,
|
||||
vec![],
|
||||
None,
|
||||
false,
|
||||
Some(StoryAudience {
|
||||
all: true,
|
||||
contact_group_ids: vec![],
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("no recipients"));
|
||||
let database = ctx.app_db.read().await.clone();
|
||||
let messages = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM messages")
|
||||
.fetch_one(&database.pool)
|
||||
.await?;
|
||||
assert_eq!(messages, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_both_reupload_requested_by_encodings() {
|
||||
assert!(parse_reupload_requested_by(None).is_empty());
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::api::runtime::API_EVENTS;
|
|||
use crate::bridge::api::{ApiEvent, ApiEventKind, RustApi};
|
||||
use crate::context::Context;
|
||||
use crate::error::{Result, TwonlyError};
|
||||
use crate::services::media_upload::MediaUploadService;
|
||||
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce, Tag};
|
||||
use prost::Message as _;
|
||||
|
|
@ -238,6 +239,12 @@ impl MediaFileService {
|
|||
return Ok(());
|
||||
}
|
||||
}
|
||||
if let Err(error) = MediaUploadService::new(&self.ctx)
|
||||
.create_story_thumbnail(media_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(media_id, %error, "could not create the story thumbnail");
|
||||
}
|
||||
let is_widget = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT is_widget_media FROM media_files WHERE media_id = ?",
|
||||
)
|
||||
|
|
@ -365,12 +372,13 @@ impl MediaFileService {
|
|||
// Widget media is marked opened the moment it arrives, since nobody
|
||||
// ever opens it in a chat. Asking only for what has not been opened
|
||||
// would therefore never ask for it at all, and the state set below
|
||||
// hides the message for good.
|
||||
// hides the message for good. A story replays after it was opened, so
|
||||
// the same holds for it.
|
||||
let targets = sqlx::query_as!(
|
||||
ReuploadTarget,
|
||||
r#"SELECT message_id, sender_id AS "sender_id!: i64" FROM messages
|
||||
WHERE media_id = ? AND sender_id IS NOT NULL
|
||||
AND (opened_at IS NULL OR is_widget_media = 1)"#,
|
||||
AND (opened_at IS NULL OR is_widget_media = 1 OR is_story = 1)"#,
|
||||
media_id,
|
||||
)
|
||||
.fetch_all(&database.pool)
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ impl MessageService {
|
|||
)
|
||||
.execute(&mut *t)
|
||||
.await?;
|
||||
Group::record_text_or_media(&mut t, &group_id, timestamp / 1000).await?;
|
||||
// The commit hook installed on the connection publishes `groups` and
|
||||
// `messages` on its own, so the chat list is already on its way.
|
||||
t.commit().await?;
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ pub mod mediafiles;
|
|||
pub mod messages;
|
||||
pub mod notifications;
|
||||
pub mod outbox_dispatch;
|
||||
pub mod stories;
|
||||
pub mod webxdc;
|
||||
|
|
|
|||
|
|
@ -77,6 +77,63 @@ async fn message_sender(
|
|||
.await?)
|
||||
}
|
||||
|
||||
/// Whether `message_id` is one of this device's own story rows, which is what
|
||||
/// a reply, reaction or save aimed at the user's story points at.
|
||||
async fn is_own_story(transaction: &mut Transaction<'_, Sqlite>, message_id: &str) -> Result<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.
|
||||
///
|
||||
/// A wake-up is not free to get wrong. iOS renders the alert the push carried
|
||||
|
|
@ -119,6 +176,9 @@ pub(crate) async fn should_wake_receiver(
|
|||
.is_ok_and(|kind| kind != encrypted_content::media::Type::Reupload)
|
||||
})
|
||||
|| reaction_reaches_author
|
||||
// The sender decided, and wrote into the story, that this is the first
|
||||
// item in a day; `record_incoming_event` announces exactly those.
|
||||
|| content.story.as_ref().is_some_and(|story| story.notify)
|
||||
|| content
|
||||
.media_update
|
||||
.as_ref()
|
||||
|
|
@ -250,18 +310,28 @@ pub(crate) async fn record_incoming_event(
|
|||
let conversation_id = content.group_id.clone();
|
||||
let now = current_time().timestamp();
|
||||
let draft = if let Some(message) = content.text_message.as_ref() {
|
||||
// An answer to the user's own story says so: an emoji is a reaction
|
||||
// to it, anything else a reply.
|
||||
let about_story = match message.quote_message_id.as_deref() {
|
||||
Some(quoted) => is_own_story(transaction, quoted).await?,
|
||||
None => false,
|
||||
};
|
||||
let (kind, reaction) = match (about_story, message.quote_message_id.is_some()) {
|
||||
(true, _) if is_emoji_only(&message.text) => {
|
||||
("story_reaction", Some(message.text.trim().to_owned()))
|
||||
}
|
||||
(true, _) => ("story_reply", None),
|
||||
(false, true) => ("response", None),
|
||||
(false, false) => ("text", None),
|
||||
};
|
||||
Some(NotificationDraft {
|
||||
event_id: receipt_id.to_owned(),
|
||||
notification_id: message.sender_message_id.clone(),
|
||||
conversation_id,
|
||||
sender_id: from_user_id,
|
||||
message_id: Some(message.sender_message_id.clone()),
|
||||
kind: if message.quote_message_id.is_some() {
|
||||
"response"
|
||||
} else {
|
||||
"text"
|
||||
},
|
||||
content: None,
|
||||
kind,
|
||||
content: reaction,
|
||||
created_at: milliseconds_to_seconds(message.timestamp),
|
||||
})
|
||||
} else if let Some(media) = content.media.as_ref() {
|
||||
|
|
@ -288,6 +358,35 @@ pub(crate) async fn record_incoming_event(
|
|||
created_at: milliseconds_to_seconds(media.timestamp),
|
||||
})
|
||||
}
|
||||
} else if let Some(story) = content.story.as_ref() {
|
||||
// Only the first story in a day woke this device, and the sender says
|
||||
// which one that was. Whether to announce it at all is this device's
|
||||
// call alone: muted, or from someone the user has not written with in
|
||||
// two weeks, it stays quiet. A wake-up it declines is silenced by the
|
||||
// notification extension instead of showing the push's placeholder.
|
||||
let config = UserConfig::load_required_from(ctx)?;
|
||||
// The same chat `handle_story` filed the story under.
|
||||
let chat =
|
||||
crate::database::app::tables::Group::direct_chat_id(ctx.user_id().await?, from_user_id);
|
||||
match story.media.as_ref() {
|
||||
Some(media) if story.notify && config.story_notifications => {
|
||||
let filed = message_sender(transaction, &media.sender_message_id).await?
|
||||
== Some(Some(from_user_id));
|
||||
let recent =
|
||||
crate::services::stories::exchanged_recently(transaction, &chat, now).await?;
|
||||
(filed && recent).then(|| NotificationDraft {
|
||||
event_id: receipt_id.to_owned(),
|
||||
notification_id: media.sender_message_id.clone(),
|
||||
conversation_id: Some(chat.clone()),
|
||||
sender_id: from_user_id,
|
||||
message_id: Some(media.sender_message_id.clone()),
|
||||
kind: "story",
|
||||
content: None,
|
||||
created_at: milliseconds_to_seconds(media.timestamp).min(now),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else if let Some(message) = content.additional_data_message.as_ref() {
|
||||
// A hidden message is state a feature exchanges and leaves no row of
|
||||
// its own, so the only thing in one worth an alert is an app's
|
||||
|
|
@ -331,6 +430,11 @@ pub(crate) async fn record_incoming_event(
|
|||
// else in the group watched it land on somebody else's message and has
|
||||
// nothing to be told, and a reaction the sender put on their own
|
||||
// message concerns nobody here at all.
|
||||
let kind = if is_own_story(transaction, &reaction.target_message_id).await? {
|
||||
"story_reaction"
|
||||
} else {
|
||||
"reaction"
|
||||
};
|
||||
matches!(
|
||||
message_sender(transaction, &reaction.target_message_id).await?,
|
||||
Some(None)
|
||||
|
|
@ -341,13 +445,15 @@ pub(crate) async fn record_incoming_event(
|
|||
conversation_id,
|
||||
sender_id: from_user_id,
|
||||
message_id: Some(reaction.target_message_id.clone()),
|
||||
kind: "reaction",
|
||||
kind,
|
||||
content: Some(reaction.emoji.clone()),
|
||||
created_at: now,
|
||||
})
|
||||
} else if let Some(update) = content.media_update.as_ref() {
|
||||
let update_type = encrypted_content::media_update::Type::try_from(update.r#type)?;
|
||||
let story = is_own_story(transaction, &update.target_message_id).await?;
|
||||
let kind = match update_type {
|
||||
encrypted_content::media_update::Type::Stored if story => Some("stored_story"),
|
||||
encrypted_content::media_update::Type::Stored => Some("stored_media"),
|
||||
encrypted_content::media_update::Type::Reopened => Some("reopened_media"),
|
||||
encrypted_content::media_update::Type::DecryptionError => None,
|
||||
|
|
@ -410,7 +516,7 @@ async fn clear_stale_opened(database: &Arc<AppDatabase>) -> Result<()> {
|
|||
UPDATE notification_outbox
|
||||
SET cleared_at = ?
|
||||
WHERE cleared_at IS NULL
|
||||
AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc')
|
||||
AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc', 'story', 'story_reply', 'story_reaction')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM messages
|
||||
|
|
@ -616,6 +722,12 @@ pub async fn finalize_wakeup(deadline_ms: u64) -> Result<()> {
|
|||
Ok(Ok(())) => {}
|
||||
}
|
||||
|
||||
match tokio::time::timeout(remaining(), crate::services::stories::purge_expired(&ctx)).await {
|
||||
Ok(Err(error)) => tracing::warn!(%error, "story purge failed during wake-up"),
|
||||
Err(error) => tracing::info!(%error, "story purge window elapsed"),
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
|
||||
if owns_connection && ctx.is_notification_runtime() {
|
||||
// Bounded on its own rather than out of what is left: closing costs a
|
||||
// round trip at most, and a server that never answers the handshake
|
||||
|
|
@ -726,7 +838,7 @@ pub(crate) async fn clear_opened_messages(
|
|||
SET cleared_at = ?
|
||||
WHERE message_id = ?
|
||||
AND cleared_at IS NULL
|
||||
AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc')
|
||||
AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc', 'story', 'story_reply', 'story_reaction')
|
||||
"#,
|
||||
)
|
||||
.bind(cleared_at)
|
||||
|
|
@ -821,6 +933,10 @@ fn localized_body(locale: &str, row: &PendingRow) -> String {
|
|||
"contact_request" => "notificationContactRequest",
|
||||
"accept_request" => "notificationAcceptRequest",
|
||||
"stored_media" => "notificationStoredMediaFile",
|
||||
"story" => "notificationStory",
|
||||
"story_reply" => "notificationStoryReply",
|
||||
"story_reaction" => "notificationStoryReaction",
|
||||
"stored_story" => "notificationStoredStory",
|
||||
"reopened_media" => "notificationReopenedMedia",
|
||||
"reaction" => match (
|
||||
row.target_message_type.as_deref(),
|
||||
|
|
@ -979,6 +1095,108 @@ mod tests {
|
|||
assert_eq!(localized_body("fr-FR", &row), "wants to connect with you.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tells_a_reaction_from_a_reply() {
|
||||
for reaction in ["🔥", " ❤️ ", "👍🏽", "👨👩👧", "1️⃣", "🇩🇪", "😂😂"]
|
||||
{
|
||||
assert!(is_emoji_only(reaction), "{reaction:?} is a reaction");
|
||||
}
|
||||
for reply in ["", "nice", "nice 🔥", "12", "#", "🔥 wow"] {
|
||||
assert!(!is_emoji_only(reply), "{reply:?} is a reply");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_what_happened_to_a_story() {
|
||||
let mut reaction = pending_row("story_reaction");
|
||||
reaction.content = Some("🔥".into());
|
||||
assert_eq!(
|
||||
localized_body("en", &reaction),
|
||||
"has reacted with 🔥 to your story."
|
||||
);
|
||||
assert_eq!(
|
||||
localized_body("de", &reaction),
|
||||
"hat mit 🔥 auf deine Story reagiert."
|
||||
);
|
||||
assert_eq!(
|
||||
localized_body("en", &pending_row("story_reply")),
|
||||
"has replied to your story."
|
||||
);
|
||||
assert_eq!(
|
||||
localized_body("de", &pending_row("stored_story")),
|
||||
"hat deine Story gespeichert."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn answers_to_the_own_story_are_announced_as_such() -> Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let ctx =
|
||||
Context::init_for_testing(directory.path().join("db"), directory.path().join("data"))
|
||||
.await?;
|
||||
UserConfig::save_json(
|
||||
&ctx,
|
||||
r#"{"userId":42,"username":"ben","displayName":"Ben"}"#,
|
||||
)?;
|
||||
let database = ctx.app_db.read().await.clone();
|
||||
let mut tx = database.pool.begin().await?;
|
||||
for statement in [
|
||||
"INSERT INTO contacts(user_id, username, accepted) VALUES (7, 'anna', 1)",
|
||||
"INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES ('d', 'Anna', 1)",
|
||||
"INSERT INTO messages(message_id, group_id, type, is_story) VALUES ('mine', 'd', 'media', 1)",
|
||||
"INSERT INTO messages(message_id, group_id, type, content) VALUES ('plain', 'd', 'text', 'hi')",
|
||||
] {
|
||||
sqlx::query(sqlx::AssertSqlSafe(statement))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let text = |id: &str, text: &str, quote: &str| proto::EncryptedContent {
|
||||
group_id: Some("d".into()),
|
||||
text_message: Some(encrypted_content::TextMessage {
|
||||
sender_message_id: id.into(),
|
||||
text: text.into(),
|
||||
quote_message_id: Some(quote.into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
record_incoming_event(&ctx, &mut tx, 7, "r1", &text("t1", "🔥", "mine")).await?;
|
||||
record_incoming_event(&ctx, &mut tx, 7, "r2", &text("t2", "love it", "mine")).await?;
|
||||
record_incoming_event(&ctx, &mut tx, 7, "r3", &text("t3", "🔥", "plain")).await?;
|
||||
let stored = proto::EncryptedContent {
|
||||
media_update: Some(encrypted_content::MediaUpdate {
|
||||
r#type: encrypted_content::media_update::Type::Stored as i32,
|
||||
target_message_id: "mine".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
record_incoming_event(&ctx, &mut tx, 7, "r4", &stored).await?;
|
||||
|
||||
let rows: Vec<(String, Option<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]
|
||||
fn localizes_connection_fallback_notification() {
|
||||
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