mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 11:52:12 +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
|
## 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue