From 8c84d802fdbd811e8b55ce88fdbfce710b394e49 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 16 May 2026 00:31:54 +0200 Subject: [PATCH] Automatically mark identical media as opened across all chats --- CHANGELOG.md | 1 + lib/src/database/daos/mediafiles.dao.dart | 21 ++++++++- .../generated/app_localizations.dart | 12 +++++ .../generated/app_localizations_de.dart | 8 ++++ .../generated/app_localizations_en.dart | 8 ++++ lib/src/localization/translations | 2 +- lib/src/model/json/userdata.model.dart | 3 ++ .../services/api/mediafiles/download.api.dart | 2 + .../mediafiles/mediafile.service.dart | 12 +++-- .../services/memories/memories.service.dart | 46 +++++++++++-------- .../visual/views/chats/media_viewer.view.dart | 21 ++++++++- .../settings/chat/chat_settings.view.dart | 43 ++++++++++++++--- 12 files changed, 146 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f59a7c..ffda2166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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. - Fix: Reliability of receiving media files. diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index ec8a71c8..24c91479 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -69,7 +69,6 @@ class MediaFilesDao extends DatabaseAccessor return (select(mediaFiles)..where((t) => t.mediaId.isIn(mediaIds))).get(); } - Future getDraftMediaFile() async { final medias = await (select( mediaFiles, @@ -166,4 +165,24 @@ class MediaFilesDao extends DatabaseAccessor ), ); } + + Future> 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(); + } } diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index fb92d6a4..9e322093 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -482,6 +482,18 @@ abstract class AppLocalizations { /// **'Preselected reaction emojis'** 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. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 6f01aea3..1c25d7ce 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -214,6 +214,14 @@ class AppLocalizationsDe extends AppLocalizations { @override 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 String get settingsPreSelectedReactionsError => 'Es können maximal 12 Reaktionen ausgewählt werden.'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 920184bf..2fb3177e 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -211,6 +211,14 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get settingsPreSelectedReactionsError => 'A maximum of 12 reactions can be selected.'; diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 10b4bfed..f649128f 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 10b4bfedcc6c99e4ba0374b306610d297b9e2276 +Subproject commit f649128fd875a12f23518ff2641190cc129a9339 diff --git a/lib/src/model/json/userdata.model.dart b/lib/src/model/json/userdata.model.dart index e39fac5e..450b4df7 100644 --- a/lib/src/model/json/userdata.model.dart +++ b/lib/src/model/json/userdata.model.dart @@ -57,6 +57,9 @@ class UserData { @JsonKey(defaultValue: false) bool requestedAudioPermission = false; + @JsonKey(defaultValue: false) + bool automaticallyMarkEqualMediaFilesAsOpened = false; + @JsonKey(defaultValue: true) bool videoStabilizationEnabled = true; diff --git a/lib/src/services/api/mediafiles/download.api.dart b/lib/src/services/api/mediafiles/download.api.dart index 916d45cd..541a06da 100644 --- a/lib/src/services/api/mediafiles/download.api.dart +++ b/lib/src/services/api/mediafiles/download.api.dart @@ -353,6 +353,8 @@ Future handleEncryptedFile(String mediaId) async { ), ); + await mediaService.hashMediaFile(); + Log.info('Decryption of $mediaId was successful'); mediaService.encryptedPath.deleteSync(); diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index fc1d4784..f499e797 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -284,15 +284,19 @@ class MediaFileService { ); } unawaited(createThumbnail()); - await hashStoredMedia(); + await hashMediaFile(); // updateFromDb is done in hashStoredMedia() } - Future hashStoredMedia() async { - if (!storedPath.existsSync()) { + Future hashMediaFile() async { + late final List checksum; + if (storedPath.existsSync()) { + checksum = await sha256File(storedPath); + } else if (tempPath.existsSync()) { + checksum = await sha256File(tempPath); + } else { return; } - final checksum = await sha256File(storedPath); await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, MediaFilesCompanion( diff --git a/lib/src/services/memories/memories.service.dart b/lib/src/services/memories/memories.service.dart index 4c275f43..aede9c19 100644 --- a/lib/src/services/memories/memories.service.dart +++ b/lib/src/services/memories/memories.service.dart @@ -85,8 +85,9 @@ class MemoriesService { .whereType() .toList(); - final mediaFiles = - await twonlyDB.mediaFilesDao.getMediaFilesByIds(mediaIds); + final mediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds( + mediaIds, + ); final mediaFileMap = {for (final m in mediaFiles) m.mediaId: m}; final allContacts = await twonlyDB.contactsDao.getAllContacts(); @@ -108,8 +109,9 @@ class MemoriesService { final mediaService = MediaFileService(mediaFile); if (!mediaService.imagePreviewAvailable) continue; - final contact = - senderUserId != null ? contactMap[senderUserId] : null; + final contact = senderUserId != null + ? contactMap[senderUserId] + : null; final item = MemoryItem( mediaService: mediaService, messages: [], @@ -132,7 +134,8 @@ class MemoriesService { for (var i = 0; i < tempGalleryItems.length; i++) { final mFile = tempGalleryItems[i].mediaService.mediaFile; - final month = mFile.createdAtMonth ?? + final month = + mFile.createdAtMonth ?? DateFormat('MMMM yyyy').format(mFile.createdAt); if (lastMonth != month) { lastMonth = month; @@ -143,8 +146,9 @@ class MemoriesService { for (final list in tempGalleryItemsLastYears.values) { list.sort( - (a, b) => b.mediaService.mediaFile.createdAt - .compareTo(a.mediaService.mediaFile.createdAt), + (a, b) => b.mediaService.mediaFile.createdAt.compareTo( + a.mediaService.mediaFile.createdAt, + ), ); } @@ -167,10 +171,10 @@ class MemoriesService { Future _initAsync() async { try { // 1. Perform Inventory / Migration of non-hashed stored files - final nonHashedFiles = - await twonlyDB.mediaFilesDao.getAllNonHashedStoredMediaFiles(); - final unanalyzedFiles = - await twonlyDB.mediaFilesDao.getAllUnanalyzedStoredMediaFiles(); + final nonHashedFiles = await twonlyDB.mediaFilesDao + .getAllNonHashedStoredMediaFiles(); + final unanalyzedFiles = await twonlyDB.mediaFilesDao + .getAllUnanalyzedStoredMediaFiles(); final totalToMigrate = nonHashedFiles.length + unanalyzedFiles.length; if (totalToMigrate > 0) { @@ -178,7 +182,7 @@ class MemoriesService { for (final mediaFile in nonHashedFiles) { final mediaService = MediaFileService(mediaFile); - await mediaService.hashStoredMedia(); + await mediaService.hashMediaFile(); _updateState(filesToMigrate: _currentState.filesToMigrate - 1); } @@ -209,8 +213,9 @@ class MemoriesService { // High-performance batch DB fetch for sender attribution via Messages table mapping final mediaIds = mediaFiles.map((m) => m.mediaId).toList(); - final allMessages = - await twonlyDB.messagesDao.getMessagesByMediaIds(mediaIds); + final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds( + mediaIds, + ); final allContacts = await twonlyDB.contactsDao.getAllContacts(); final contactMap = {for (final c in allContacts) c.userId: c}; @@ -255,8 +260,9 @@ class MemoriesService { // Sort descending by creation date tempGalleryItems.sort( - (a, b) => b.mediaService.mediaFile.createdAt - .compareTo(a.mediaService.mediaFile.createdAt), + (a, b) => b.mediaService.mediaFile.createdAt.compareTo( + a.mediaService.mediaFile.createdAt, + ), ); final tempOrderedByMonth = >{}; @@ -266,7 +272,8 @@ class MemoriesService { // High performance grouping leveraging pre-computed createdAtMonth column for (var i = 0; i < tempGalleryItems.length; i++) { final mFile = tempGalleryItems[i].mediaService.mediaFile; - final month = mFile.createdAtMonth ?? + final month = + mFile.createdAtMonth ?? DateFormat('MMMM yyyy').format(mFile.createdAt); if (lastMonth != month) { lastMonth = month; @@ -277,8 +284,9 @@ class MemoriesService { for (final list in tempGalleryItemsLastYears.values) { list.sort( - (a, b) => b.mediaService.mediaFile.createdAt - .compareTo(a.mediaService.mediaFile.createdAt), + (a, b) => b.mediaService.mediaFile.createdAt.compareTo( + a.mediaService.mediaFile.createdAt, + ), ); } diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index 16ca569c..eda8e12e 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -339,9 +339,28 @@ class _MediaViewerViewState extends State { } } + 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( currentMessage!.senderId!, - [currentMessage!.messageId], + markAsOpenMessageIDs, ); if (!mounted) return; diff --git a/lib/src/visual/views/settings/chat/chat_settings.view.dart b/lib/src/visual/views/settings/chat/chat_settings.view.dart index db3667be..56c1a66b 100644 --- a/lib/src/visual/views/settings/chat/chat_settings.view.dart +++ b/lib/src/visual/views/settings/chat/chat_settings.view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.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/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; class ChatSettingsView extends StatefulWidget { @@ -16,19 +18,46 @@ class _ChatSettingsViewState extends State { super.initState(); } + Future setAutomaticallyMarkEqualMediaFilesAsOpened(bool value) async { + await UserService.update((u) { + u.automaticallyMarkEqualMediaFilesAsOpened = value; + }); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(context.lang.settingsChats), ), - body: ListView( - children: [ - ListTile( - title: Text(context.lang.settingsPreSelectedReactions), - onTap: () => context.push(Routes.settingsChatsReactions), - ), - ], + body: StreamBuilder( + stream: userService.onUserUpdated, + builder: (context, snapshot) { + return ListView( + 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, + ), + ], + ); + }, ), ); }