Automatically mark identical media as opened across all chats
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-05-16 00:31:54 +02:00
parent ebc643cbe4
commit 8c84d802fd
12 changed files with 146 additions and 33 deletions

View file

@ -2,6 +2,7 @@
## 0.2.12 ## 0.2.12
- New: Automatically mark identical media as opened across all chats (Settings > Chats).
- Improved: Memories viewer redesigned with smoother animations and new quick-action controls. - Improved: Memories viewer redesigned with smoother animations and new quick-action controls.
- Fix: Reliability of receiving media files. - Fix: Reliability of receiving media files.

View file

@ -69,7 +69,6 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
return (select(mediaFiles)..where((t) => t.mediaId.isIn(mediaIds))).get(); return (select(mediaFiles)..where((t) => t.mediaId.isIn(mediaIds))).get();
} }
Future<MediaFile?> getDraftMediaFile() async { Future<MediaFile?> getDraftMediaFile() async {
final medias = await (select( final medias = await (select(
mediaFiles, mediaFiles,
@ -166,4 +165,24 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
), ),
); );
} }
Future<List<String>> getMessageIdsByMediaHash(
Uint8List hash,
int senderId,
) async {
final query =
select(db.messages).join([
innerJoin(
mediaFiles,
mediaFiles.mediaId.equalsExp(db.messages.mediaId),
),
])..where(
mediaFiles.storedFileHash.equals(hash) &
db.messages.senderId.equals(senderId) &
db.messages.openedAt.isNull(),
);
final rows = await query.get();
return rows.map((row) => row.readTable(db.messages).messageId).toList();
}
} }

View file

@ -482,6 +482,18 @@ abstract class AppLocalizations {
/// **'Preselected reaction emojis'** /// **'Preselected reaction emojis'**
String get settingsPreSelectedReactions; String get settingsPreSelectedReactions;
/// No description provided for @settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle.
///
/// In en, this message translates to:
/// **'Mark duplicates as opened'**
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle;
/// No description provided for @settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle.
///
/// In en, this message translates to:
/// **'Automatically marks identical media files as opened across all chats.'**
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle;
/// No description provided for @settingsPreSelectedReactionsError. /// No description provided for @settingsPreSelectedReactionsError.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -214,6 +214,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsPreSelectedReactions => 'Vorgewählte Reaktions-Emojis'; String get settingsPreSelectedReactions => 'Vorgewählte Reaktions-Emojis';
@override
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle =>
'Duplikate als geöffnet markieren';
@override
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle =>
'Markiert identische Mediendateien automatisch in allen Chats als geöffnet.';
@override @override
String get settingsPreSelectedReactionsError => String get settingsPreSelectedReactionsError =>
'Es können maximal 12 Reaktionen ausgewählt werden.'; 'Es können maximal 12 Reaktionen ausgewählt werden.';

View file

@ -211,6 +211,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsPreSelectedReactions => 'Preselected reaction emojis'; String get settingsPreSelectedReactions => 'Preselected reaction emojis';
@override
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle =>
'Mark duplicates as opened';
@override
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle =>
'Automatically marks identical media files as opened across all chats.';
@override @override
String get settingsPreSelectedReactionsError => String get settingsPreSelectedReactionsError =>
'A maximum of 12 reactions can be selected.'; 'A maximum of 12 reactions can be selected.';

@ -1 +1 @@
Subproject commit 10b4bfedcc6c99e4ba0374b306610d297b9e2276 Subproject commit f649128fd875a12f23518ff2641190cc129a9339

View file

@ -57,6 +57,9 @@ class UserData {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
bool requestedAudioPermission = false; bool requestedAudioPermission = false;
@JsonKey(defaultValue: false)
bool automaticallyMarkEqualMediaFilesAsOpened = false;
@JsonKey(defaultValue: true) @JsonKey(defaultValue: true)
bool videoStabilizationEnabled = true; bool videoStabilizationEnabled = true;

View file

@ -353,6 +353,8 @@ Future<void> handleEncryptedFile(String mediaId) async {
), ),
); );
await mediaService.hashMediaFile();
Log.info('Decryption of $mediaId was successful'); Log.info('Decryption of $mediaId was successful');
mediaService.encryptedPath.deleteSync(); mediaService.encryptedPath.deleteSync();

View file

@ -284,15 +284,19 @@ class MediaFileService {
); );
} }
unawaited(createThumbnail()); unawaited(createThumbnail());
await hashStoredMedia(); await hashMediaFile();
// updateFromDb is done in hashStoredMedia() // updateFromDb is done in hashStoredMedia()
} }
Future<void> hashStoredMedia() async { Future<void> hashMediaFile() async {
if (!storedPath.existsSync()) { late final List<int> checksum;
if (storedPath.existsSync()) {
checksum = await sha256File(storedPath);
} else if (tempPath.existsSync()) {
checksum = await sha256File(tempPath);
} else {
return; return;
} }
final checksum = await sha256File(storedPath);
await twonlyDB.mediaFilesDao.updateMedia( await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId, mediaFile.mediaId,
MediaFilesCompanion( MediaFilesCompanion(

View file

@ -85,8 +85,9 @@ class MemoriesService {
.whereType<String>() .whereType<String>()
.toList(); .toList();
final mediaFiles = final mediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds(
await twonlyDB.mediaFilesDao.getMediaFilesByIds(mediaIds); mediaIds,
);
final mediaFileMap = {for (final m in mediaFiles) m.mediaId: m}; final mediaFileMap = {for (final m in mediaFiles) m.mediaId: m};
final allContacts = await twonlyDB.contactsDao.getAllContacts(); final allContacts = await twonlyDB.contactsDao.getAllContacts();
@ -108,8 +109,9 @@ class MemoriesService {
final mediaService = MediaFileService(mediaFile); final mediaService = MediaFileService(mediaFile);
if (!mediaService.imagePreviewAvailable) continue; if (!mediaService.imagePreviewAvailable) continue;
final contact = final contact = senderUserId != null
senderUserId != null ? contactMap[senderUserId] : null; ? contactMap[senderUserId]
: null;
final item = MemoryItem( final item = MemoryItem(
mediaService: mediaService, mediaService: mediaService,
messages: [], messages: [],
@ -132,7 +134,8 @@ class MemoriesService {
for (var i = 0; i < tempGalleryItems.length; i++) { for (var i = 0; i < tempGalleryItems.length; i++) {
final mFile = tempGalleryItems[i].mediaService.mediaFile; final mFile = tempGalleryItems[i].mediaService.mediaFile;
final month = mFile.createdAtMonth ?? final month =
mFile.createdAtMonth ??
DateFormat('MMMM yyyy').format(mFile.createdAt); DateFormat('MMMM yyyy').format(mFile.createdAt);
if (lastMonth != month) { if (lastMonth != month) {
lastMonth = month; lastMonth = month;
@ -143,8 +146,9 @@ class MemoriesService {
for (final list in tempGalleryItemsLastYears.values) { for (final list in tempGalleryItemsLastYears.values) {
list.sort( list.sort(
(a, b) => b.mediaService.mediaFile.createdAt (a, b) => b.mediaService.mediaFile.createdAt.compareTo(
.compareTo(a.mediaService.mediaFile.createdAt), a.mediaService.mediaFile.createdAt,
),
); );
} }
@ -167,10 +171,10 @@ class MemoriesService {
Future<void> _initAsync() async { Future<void> _initAsync() async {
try { try {
// 1. Perform Inventory / Migration of non-hashed stored files // 1. Perform Inventory / Migration of non-hashed stored files
final nonHashedFiles = final nonHashedFiles = await twonlyDB.mediaFilesDao
await twonlyDB.mediaFilesDao.getAllNonHashedStoredMediaFiles(); .getAllNonHashedStoredMediaFiles();
final unanalyzedFiles = final unanalyzedFiles = await twonlyDB.mediaFilesDao
await twonlyDB.mediaFilesDao.getAllUnanalyzedStoredMediaFiles(); .getAllUnanalyzedStoredMediaFiles();
final totalToMigrate = nonHashedFiles.length + unanalyzedFiles.length; final totalToMigrate = nonHashedFiles.length + unanalyzedFiles.length;
if (totalToMigrate > 0) { if (totalToMigrate > 0) {
@ -178,7 +182,7 @@ class MemoriesService {
for (final mediaFile in nonHashedFiles) { for (final mediaFile in nonHashedFiles) {
final mediaService = MediaFileService(mediaFile); final mediaService = MediaFileService(mediaFile);
await mediaService.hashStoredMedia(); await mediaService.hashMediaFile();
_updateState(filesToMigrate: _currentState.filesToMigrate - 1); _updateState(filesToMigrate: _currentState.filesToMigrate - 1);
} }
@ -209,8 +213,9 @@ class MemoriesService {
// High-performance batch DB fetch for sender attribution via Messages table mapping // High-performance batch DB fetch for sender attribution via Messages table mapping
final mediaIds = mediaFiles.map((m) => m.mediaId).toList(); final mediaIds = mediaFiles.map((m) => m.mediaId).toList();
final allMessages = final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds(
await twonlyDB.messagesDao.getMessagesByMediaIds(mediaIds); mediaIds,
);
final allContacts = await twonlyDB.contactsDao.getAllContacts(); final allContacts = await twonlyDB.contactsDao.getAllContacts();
final contactMap = {for (final c in allContacts) c.userId: c}; final contactMap = {for (final c in allContacts) c.userId: c};
@ -255,8 +260,9 @@ class MemoriesService {
// Sort descending by creation date // Sort descending by creation date
tempGalleryItems.sort( tempGalleryItems.sort(
(a, b) => b.mediaService.mediaFile.createdAt (a, b) => b.mediaService.mediaFile.createdAt.compareTo(
.compareTo(a.mediaService.mediaFile.createdAt), a.mediaService.mediaFile.createdAt,
),
); );
final tempOrderedByMonth = <String, List<int>>{}; final tempOrderedByMonth = <String, List<int>>{};
@ -266,7 +272,8 @@ class MemoriesService {
// High performance grouping leveraging pre-computed createdAtMonth column // High performance grouping leveraging pre-computed createdAtMonth column
for (var i = 0; i < tempGalleryItems.length; i++) { for (var i = 0; i < tempGalleryItems.length; i++) {
final mFile = tempGalleryItems[i].mediaService.mediaFile; final mFile = tempGalleryItems[i].mediaService.mediaFile;
final month = mFile.createdAtMonth ?? final month =
mFile.createdAtMonth ??
DateFormat('MMMM yyyy').format(mFile.createdAt); DateFormat('MMMM yyyy').format(mFile.createdAt);
if (lastMonth != month) { if (lastMonth != month) {
lastMonth = month; lastMonth = month;
@ -277,8 +284,9 @@ class MemoriesService {
for (final list in tempGalleryItemsLastYears.values) { for (final list in tempGalleryItemsLastYears.values) {
list.sort( list.sort(
(a, b) => b.mediaService.mediaFile.createdAt (a, b) => b.mediaService.mediaFile.createdAt.compareTo(
.compareTo(a.mediaService.mediaFile.createdAt), a.mediaService.mediaFile.createdAt,
),
); );
} }

View file

@ -339,9 +339,28 @@ class _MediaViewerViewState extends State<MediaViewerView> {
} }
} }
var markAsOpenMessageIDs = [currentMessage!.messageId];
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
currentMediaLocal.mediaFile.storedFileHash != null) {
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
currentMediaLocal.mediaFile.storedFileHash!,
currentMessage!.senderId!,
);
if (!messageIds.contains(currentMessage!.messageId)) {
Log.error(
'Original message ID was not returned from `getMessageIdsByMediaHash`.',
);
messageIds.add(currentMessage!.messageId);
}
markAsOpenMessageIDs = messageIds;
}
await notifyContactAboutOpeningMessage( await notifyContactAboutOpeningMessage(
currentMessage!.senderId!, currentMessage!.senderId!,
[currentMessage!.messageId], markAsOpenMessageIDs,
); );
if (!mounted) return; if (!mounted) return;

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
class ChatSettingsView extends StatefulWidget { class ChatSettingsView extends StatefulWidget {
@ -16,19 +18,46 @@ class _ChatSettingsViewState extends State<ChatSettingsView> {
super.initState(); super.initState();
} }
Future<void> setAutomaticallyMarkEqualMediaFilesAsOpened(bool value) async {
await UserService.update((u) {
u.automaticallyMarkEqualMediaFilesAsOpened = value;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.lang.settingsChats), title: Text(context.lang.settingsChats),
), ),
body: ListView( body: StreamBuilder<void>(
children: [ stream: userService.onUserUpdated,
ListTile( builder: (context, snapshot) {
title: Text(context.lang.settingsPreSelectedReactions), return ListView(
onTap: () => context.push(Routes.settingsChatsReactions), children: [
), ListTile(
], title: Text(context.lang.settingsPreSelectedReactions),
onTap: () => context.push(Routes.settingsChatsReactions),
),
SwitchListTile(
title: Text(
context
.lang
.settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle,
),
subtitle: Text(
context
.lang
.settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle,
),
value: userService
.currentUser
.automaticallyMarkEqualMediaFilesAsOpened,
onChanged: setAutomaticallyMarkEqualMediaFilesAsOpened,
),
],
);
},
), ),
); );
} }