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

View file

@ -69,7 +69,6 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
return (select(mediaFiles)..where((t) => t.mediaId.isIn(mediaIds))).get();
}
Future<MediaFile?> getDraftMediaFile() async {
final medias = await (select(
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'**
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:

View file

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

View file

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

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

View file

@ -57,6 +57,9 @@ class UserData {
@JsonKey(defaultValue: false)
bool requestedAudioPermission = false;
@JsonKey(defaultValue: false)
bool automaticallyMarkEqualMediaFilesAsOpened = false;
@JsonKey(defaultValue: 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');
mediaService.encryptedPath.deleteSync();

View file

@ -284,15 +284,19 @@ class MediaFileService {
);
}
unawaited(createThumbnail());
await hashStoredMedia();
await hashMediaFile();
// updateFromDb is done in hashStoredMedia()
}
Future<void> hashStoredMedia() async {
if (!storedPath.existsSync()) {
Future<void> hashMediaFile() async {
late final List<int> 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(

View file

@ -85,8 +85,9 @@ class MemoriesService {
.whereType<String>()
.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<void> _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 = <String, List<int>>{};
@ -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,
),
);
}

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(
currentMessage!.senderId!,
[currentMessage!.messageId],
markAsOpenMessageIDs,
);
if (!mounted) return;

View file

@ -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<ChatSettingsView> {
super.initState();
}
Future<void> 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<void>(
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,
),
],
);
},
),
);
}