add stories feature

This commit is contained in:
otsmr 2026-09-27 21:27:52 +02:00
parent 856a67edaa
commit 5ba1a9db88
116 changed files with 7361 additions and 801 deletions

View file

@ -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>

View file

@ -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)

View file

@ -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

View file

@ -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"
}
},
{

View file

@ -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(

View file

@ -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

View 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

View file

@ -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);

View file

@ -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);

View file

@ -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 ==

View file

@ -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());

View file

@ -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'}';

View file

@ -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);

View file

@ -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(),
);

View file

@ -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));
}

View file

@ -115,7 +115,8 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
..orderBy([(reaction) => OrderingTerm.desc(reaction.createdAt)]))
.watch();
}
Stream<Reaction?> watchLastReactions(String groupId) {
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
''',

View 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);
});
});
}
}

View 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,
);
}

View file

@ -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)();
}

View file

@ -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)();

View file

@ -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 {

View file

@ -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,
),

View file

@ -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

View file

@ -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';
}
}

View file

@ -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';
}
}

View file

@ -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';
}
}

View file

@ -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"
}
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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 =

View file

@ -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=');

View file

@ -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) {

View file

@ -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();

View 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),
],
),
],
),
);
}
}

View 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,
),
),
],
),
);
}
}

View 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,
),
);
}
}

View file

@ -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)',
),
],
),

View file

@ -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,36 +119,31 @@ 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(
for (final contactGroup in _contactGroups) ...[
const SizedBox(width: 8),
MyChip(
tooltip: contactGroup.name,
selected: _isSelected(_targets[contactGroup.id]),
onTap: () => _apply(contactGroup),
onLongPress: () => _openSettings(contactGroup),
child: ActionChip(
padding: EdgeInsets.zero,
tooltip: contactGroup.name,
onPressed: () => _apply(contactGroup),
label: Text(
contactGroup.emoji ?? contactGroup.name,
style: const TextStyle(fontSize: 18),
),
shape: const StadiumBorder(),
label: Text(
contactGroup.emoji ?? contactGroup.name,
style: const TextStyle(fontSize: 18),
),
),
],
],
),
);

View file

@ -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]!];
}

View file

@ -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),
),
],
),
),
],
);
}
}

View file

@ -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,
),
),
);

View file

@ -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(
myAvatar: true,
fontSize: 14,
color: context.color.onSurface.withAlpha(20),
),
child: switch (_activeOwnStories.lastOrNull) {
// While the user has a story, it replaces their avatar.
final OwnStoryItem newest => StoryPreview(
mediaFile: newest.mediaFile,
width: 28,
height: 28,
circle: true,
),
null => AvatarIcon(
myAvatar: true,
fontSize: 14,
color: context.color.onSurface.withAlpha(20),
),
},
),
),
const SizedBox(width: 10),
@ -446,6 +504,7 @@ class _ChatListViewState extends State<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 [],
);
},
),

View file

@ -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

View file

@ -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,
);

View file

@ -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,

View file

@ -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;
unawaited(
twonlyDB.groupsDao.updateGroup(
widget.group.groupId,
GroupsCompanion(
draftMessage: Value(draftText.isEmpty ? null : draftText),
if (!_isStory) {
unawaited(
twonlyDB.groupsDao.updateGroup(
widget.group!.groupId,
GroupsCompanion(
draftMessage: Value(draftText.isEmpty ? null : draftText),
),
),
),
);
);
}
textMessageController.dispose();
super.dispose();
@ -132,9 +175,54 @@ class _MediaViewerViewState extends State<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}',
);
await RustApi.requestMediaReupload(
mediaId: currentMediaLocal.mediaFile.mediaId,
);
if (!_isOwnStory) {
await RustApi.requestMediaReupload(
mediaId: currentMediaLocal.mediaFile.mediaId,
);
}
return advanceToNextMediaOrExit();
}
// The server can now delete the encrypted bytes, as the user has successfully opened it.
Log.info(
'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}',
);
unawaitedRustCall(
RustApi.downloadDone(token: currentMediaLocal.mediaFile.downloadToken!),
'downloadDone',
);
final downloadToken = currentMediaLocal.mediaFile.downloadToken;
if (downloadToken != null) {
Log.info(
'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}',
);
unawaitedRustCall(
RustApi.downloadDone(token: downloadToken),
'downloadDone',
);
}
if (currentMediaLocal.mediaFile.type == MediaType.video) {
await _setupVideoPlayer(currentMediaLocal);
@ -383,21 +500,25 @@ class _MediaViewerViewState extends State<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(() {

View file

@ -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,

View file

@ -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),
),
],
);
}
}

View file

@ -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),

View file

@ -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;
}
}

View file

@ -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),
),
),
],
),
),
],
),
),
],
),
);
}
}

View file

@ -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,
),
);
}

View file

@ -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,

View file

@ -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(

View file

@ -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,19 +265,31 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
final draftMedia = await twonlyDB.mediaFilesDao.getDraftMediaFile();
if (draftMedia != null) {
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ShareImageEditorView(
mediaFileService: MediaFileService(draftMedia),
sharedFromGallery: true,
),
),
);
await _openEditor(MediaFileService(draftMedia));
}
}
/// Opens the editor on media that did not come from the camera, such as a
/// restored draft. The camera may already be running, started with the
/// app, and would keep running underneath the editor for as long as it is
/// open. It is closed, and the camera page counts as hidden until the
/// editor is left, so nothing starts it again in the meantime; the camera
/// page starts it itself once it shows again.
Future<void> _openEditor(MediaFileService mediaFileService) async {
if (!mounted) return;
_disableCameraTimer?.cancel();
setState(() => _editorOpen = true);
await _mainCameraController.closeCamera();
if (!mounted) return;
await context.navPush(
ShareImageEditorView(
mediaFileService: mediaFileService,
sharedFromGallery: true,
),
);
if (mounted) setState(() => _editorOpen = false);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
@ -453,7 +461,9 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
child: CameraPreviewControllerView(
mainController: _mainCameraController,
isVisible:
((1 - (_offsetRatio * 4) % 1) == 1) && _activePageIdx == 1,
((1 - (_offsetRatio * 4) % 1) == 1) &&
_activePageIdx == 1 &&
!_editorOpen,
),
),
),

View file

@ -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),

View 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),
],
);
}
}

View file

@ -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(

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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,
&notification_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 {

View file

@ -57,6 +57,7 @@ pub(crate) async fn handle_media(
from_user_id: i64,
group_id: &str,
media: encrypted_content::Media,
is_story: bool,
) -> Result<()> {
let media_type = MediaType::try_from(media.r#type)?;
let widget_only = media.widget_only == Some(true);
@ -225,10 +226,11 @@ pub(crate) async fn handle_media(
additional_message_data,
quotes_message_id,
is_widget_media,
is_story,
opened_at,
opened_by_all,
created_at
) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?, ?)
"#,
group_id,
media.sender_message_id,
@ -237,6 +239,7 @@ pub(crate) async fn handle_media(
media.additional_message_data,
media.quote_message_id,
widget_only,
is_story,
opened_at,
opened_by_all,
timestamp,
@ -244,9 +247,12 @@ pub(crate) async fn handle_media(
.execute(&mut **t)
.await?;
if !widget_only {
// A story is not an exchange in the chat it is filed under: it neither
// moves the chat up nor feeds its flames.
if !widget_only && !is_story {
Group::increase_last_message_exchange(t, group_id, timestamp).await?;
Group::record_media_exchange(t, group_id, true, timestamp).await?;
Group::record_text_or_media(t, group_id, timestamp).await?;
}
spawn_media_download(ctx, media_id);
@ -300,7 +306,18 @@ pub(crate) async fn handle_media_update(
.execute(&mut **t)
.await?;
spawn_media_store(ctx, media_id.clone());
// Every recipient of a story shares one media file here, so the
// second person saving it must not export it to the gallery again.
let already_stored = sqlx::query_scalar!(
r#"SELECT stored AS "stored: bool" FROM media_files WHERE media_id = ?"#,
media_id,
)
.fetch_optional(&mut **t)
.await?
.unwrap_or(false);
if !already_stored {
spawn_media_store(ctx, media_id.clone());
}
}
MediaUpdateType::DecryptionError => {
// The upload state and the requester list are owned by the reupload

View file

@ -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)
}
}
}

View 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
);
}
}

View file

@ -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(

View file

@ -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() {

View file

@ -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;

View file

@ -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
}

View file

@ -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;

View 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
}

View 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));

View 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);

View file

@ -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.

View file

@ -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,

View file

@ -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.

View file

@ -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 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(

View file

@ -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");
}

View file

@ -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,23 +1000,55 @@ impl DirectMediaUploadService {
for recipient in recipients {
let mut download_token = vec![0_u8; 32];
rand::rng().fill_bytes(&mut download_token);
let content = EncryptedContent {
group_id: Some(message.group_id.clone()),
media: Some(encrypted_content::Media {
sender_message_id: message.message_id.clone(),
r#type: media_type as i32,
display_limit_in_milliseconds: media.display_limit_in_milliseconds,
requires_authentication: media.requires_authentication != 0,
timestamp: message.created_at.saturating_mul(1_000),
quote_message_id: message.quotes_message_id.clone(),
download_token: Some(download_token.clone()),
encryption_key: Some(key.to_vec()),
encryption_mac: Some(tag.to_vec()),
encryption_nonce: Some(nonce.to_vec()),
additional_message_data: message.additional_message_data.clone(),
widget_only: Some(message.is_widget_media != 0),
}),
..Default::default()
let mut wake = message.is_widget_media == 0;
let content = if message.is_story != 0 {
wake = story_wakes_recipient(
&database,
&message,
media_id,
recipient.contact_id,
now,
)
.await?;
EncryptedContent {
story: Some(encrypted_content::Story {
media: Some(encrypted_content::Media {
sender_message_id: message.message_id.clone(),
r#type: media_type as i32,
display_limit_in_milliseconds: None,
requires_authentication: false,
timestamp: message.created_at.saturating_mul(1_000),
quote_message_id: None,
download_token: Some(download_token.clone()),
encryption_key: Some(key.to_vec()),
encryption_mac: Some(tag.to_vec()),
encryption_nonce: Some(nonce.to_vec()),
additional_message_data: message.additional_message_data.clone(),
widget_only: None,
}),
notify: wake,
}),
..Default::default()
}
} else {
EncryptedContent {
group_id: Some(message.group_id.clone()),
media: Some(encrypted_content::Media {
sender_message_id: message.message_id.clone(),
r#type: media_type as i32,
display_limit_in_milliseconds: media.display_limit_in_milliseconds,
requires_authentication: media.requires_authentication != 0,
timestamp: message.created_at.saturating_mul(1_000),
quote_message_id: message.quotes_message_id.clone(),
download_token: Some(download_token.clone()),
encryption_key: Some(key.to_vec()),
encryption_mac: Some(tag.to_vec()),
encryption_nonce: Some(nonce.to_vec()),
additional_message_data: message.additional_message_data.clone(),
widget_only: Some(message.is_widget_media != 0),
}),
..Default::default()
}
};
let encrypted_body = send_c2c_message_to_contact()
.ctx(&self.ctx)
@ -1023,13 +1067,14 @@ impl DirectMediaUploadService {
encrypted_body,
// Widget media arrives already opened and raises no
// notification, so a push for it only buys the recipient
// an alert with nothing behind it.
push_data: (message.is_widget_media == 0).then(|| vec![1]),
// an alert with nothing behind it. A story wakes only for
// the first item in a day, see `story_wakes_recipient`.
push_data: wake.then(|| vec![1]),
download_token,
});
}
}
if dispatches.is_empty() || dispatches.len() > 300 {
if dispatches.is_empty() || dispatches.len() > MAX_ATTACHMENT_DISPATCHES {
return Err(TwonlyError::Generic(format!(
"direct-media fan-out has {} dispatches",
dispatches.len()
@ -1046,6 +1091,64 @@ impl DirectMediaUploadService {
}
}
/// Whether a story row's recipient is woken for it. Stories wake only for the
/// first item in a day, so a burst of them is one alert rather than a stream:
///
/// - an older own story row in the same chat that has not expired yet means
/// this is not the first. Ties on the timestamp are broken by message id, so
/// two items prepared at once cannot each defer to the other.
/// - a direct copy of the same media already wakes the recipient.
/// - a row the server or recipient already acknowledged is being prepared
/// again for a reupload, and was announced the first time.
/// - the two have not sent each other a text or media message in two weeks;
/// the recipient would not announce it then, so waking them is pointless.
///
/// The answer also travels inside the story, so the receiver announces
/// exactly the items it was woken for.
async fn story_wakes_recipient(
database: &AppDatabase,
message: &MessageRow,
media_id: &str,
contact_id: i64,
now: i64,
) -> Result<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);
}
}

View file

@ -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)
}
})
.await
{
tracing::warn!(media_id, %error, "could not create a thumbnail");
render_thumbnail(
&media.media_id,
stored,
files.thumbnail_path(&media.media_id),
media.media_type == "video",
)
.await;
Ok(())
}
/// Renders the frame a reply to a video story is shown with. Stored media
/// gets its thumbnail from the stored file, but a story is replied to long
/// before anybody stores it, so this one comes from the plaintext.
pub(crate) async fn create_story_thumbnail(&self, media_id: &str) -> Result<()> {
let Some(media) = self.load(media_id).await? else {
return Ok(());
};
if media.media_type != "video" {
return Ok(());
}
let database = self.ctx.app_db.read().await.clone();
let is_story = sqlx::query_scalar::<_, i64>(
"SELECT EXISTS(SELECT 1 FROM messages WHERE media_id = ? AND is_story = 1)",
)
.bind(media_id)
.fetch_one(&database.pool)
.await?
!= 0;
let files = MediaFileService::new(&self.ctx);
let source = files.temp_path(media_id, &media.media_type);
let thumbnail = files.thumbnail_path(media_id);
if !is_story || !source.exists() || thumbnail.exists() {
return Ok(());
}
if render_thumbnail(media_id, source, thumbnail, true).await {
sqlx::query("UPDATE media_files SET has_thumbnail = 1 WHERE media_id = ?")
.bind(media_id)
.execute(&database.pool)
.await?;
}
Ok(())
}
@ -1501,6 +1611,34 @@ where
.map_err(|error| TwonlyError::Generic(error.to_string()))?
}
/// Writes a webp thumbnail of `source` to `thumbnail`. A failure only costs
/// the preview, so it is logged and reported rather than returned.
async fn render_thumbnail(
media_id: &str,
source: std::path::PathBuf,
thumbnail: std::path::PathBuf,
is_video: bool,
) -> bool {
let result = blocking(move || {
if is_video {
// Only the frame grab needs the platform's video decoder; the
// scaling and encoding is the same code stills use.
let frame = thumbnail.with_extension("frame.png");
video::extract_frame(&source, &frame)?;
let result = media_codec::create_image_thumbnail(&frame, &thumbnail);
let _ = std::fs::remove_file(&frame);
result
} else {
media_codec::create_image_thumbnail(&source, &thumbnail)
}
})
.await;
if let Err(error) = &result {
tracing::warn!(media_id, %error, "could not create a thumbnail");
}
result.is_ok()
}
fn remove_file(path: &Path) {
match std::fs::remove_file(path) {
Ok(()) => {}
@ -1531,6 +1669,42 @@ fn parse_reupload_requested_by(value: Option<&str>) -> Vec<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());

View file

@ -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)

View file

@ -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?;

View file

@ -17,4 +17,5 @@ pub mod mediafiles;
pub mod messages;
pub mod notifications;
pub mod outbox_dispatch;
pub mod stories;
pub mod webxdc;

View file

@ -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!(

View 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