mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 12:12:13 +00:00
Automatically mark identical media as opened across all chats
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
ebc643cbe4
commit
8c84d802fd
12 changed files with 146 additions and 33 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -57,6 +57,9 @@ class UserData {
|
|||
@JsonKey(defaultValue: false)
|
||||
bool requestedAudioPermission = false;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
bool automaticallyMarkEqualMediaFilesAsOpened = false;
|
||||
|
||||
@JsonKey(defaultValue: true)
|
||||
bool videoStabilizationEnabled = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -353,6 +353,8 @@ Future<void> handleEncryptedFile(String mediaId) async {
|
|||
),
|
||||
);
|
||||
|
||||
await mediaService.hashMediaFile();
|
||||
|
||||
Log.info('Decryption of $mediaId was successful');
|
||||
|
||||
mediaService.encryptedPath.deleteSync();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue