add option to reopen images with context menu
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-04-09 22:20:15 +02:00
parent 19a53a879b
commit 587740f306
5 changed files with 124 additions and 85 deletions

View file

@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.1.4
- Fix: Several minor issues with the user interface
## 0.1.3 ## 0.1.3
- New: Video stabilization - New: Video stabilization

@ -1 +1 @@
Subproject commit 662b8ddafcbf1c789f54c93da51ebb0514ba1f81 Subproject commit f633c60dfe0edf36a8ed91804dba7a2879b5bc52

View file

@ -44,8 +44,9 @@ class MediaFileService {
delete = false; delete = false;
} }
final messages = final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
await twonlyDB.messagesDao.getMessagesByMediaId(mediaId); mediaId,
);
// in case messages in empty the file will be deleted, as delete is true by default // in case messages in empty the file will be deleted, as delete is true by default
@ -63,16 +64,18 @@ class MediaFileService {
// This branch will prevent to reach the next if condition, with would otherwise store the image for two days // This branch will prevent to reach the next if condition, with would otherwise store the image for two days
// delete = true; // do not overwrite a previous delete = false // delete = true; // do not overwrite a previous delete = false
// this is just to make it easier to understand :) // this is just to make it easier to understand :)
} else if (message.openedAt! } else if (message.openedAt!.isAfter(
.isAfter(clock.now().subtract(const Duration(days: 2)))) { clock.now().subtract(const Duration(days: 2)),
)) {
// In case the image was opened, but send with unlimited time or no authentication. // In case the image was opened, but send with unlimited time or no authentication.
if (message.senderId == null) { if (message.senderId == null) {
delete = false; delete = false;
} else { } else {
// Check weather the image was send in a group. Then the images is preserved for two days in case another person stores the image. // Check weather the image was send in a group. Then the images is preserved for two days in case another person stores the image.
// This also allows to reopen this image for two days. // This also allows to reopen this image for two days.
final group = final group = await twonlyDB.groupsDao.getGroup(
await twonlyDB.groupsDao.getGroup(message.groupId); message.groupId,
);
if (group != null && !group.isDirectChat) { if (group != null && !group.isDirectChat) {
delete = false; delete = false;
} }
@ -93,8 +96,9 @@ class MediaFileService {
} }
Future<void> updateFromDB() async { Future<void> updateFromDB() async {
final updated = final updated = await twonlyDB.mediaFilesDao.getMediaFileById(
await twonlyDB.mediaFilesDao.getMediaFileById(mediaFile.mediaId); mediaFile.mediaId,
);
if (updated != null) { if (updated != null) {
mediaFile = updated; mediaFile = updated;
} }
@ -151,8 +155,9 @@ class MediaFileService {
mediaFile.mediaId, mediaFile.mediaId,
MediaFilesCompanion( MediaFilesCompanion(
requiresAuthentication: Value(requiresAuthentication), requiresAuthentication: Value(requiresAuthentication),
displayLimitInMilliseconds: displayLimitInMilliseconds: requiresAuthentication
requiresAuthentication ? const Value(12000) : const Value.absent(), ? const Value(12000)
: const Value.absent(),
), ),
); );
await updateFromDB(); await updateFromDB();
@ -208,6 +213,13 @@ class MediaFileService {
} }
} }
// Media was send with unlimited display limit time and without auth required
// and the temp media file still exists, then the media file can be reopened again...
bool get canBeOpenedAgain =>
!mediaFile.requiresAuthentication &&
mediaFile.displayLimitInMilliseconds == null &&
tempPath.existsSync();
bool get imagePreviewAvailable => bool get imagePreviewAvailable =>
thumbnailPath.existsSync() || storedPath.existsSync(); thumbnailPath.existsSync() || storedPath.existsSync();
@ -293,8 +305,10 @@ class MediaFileService {
extension = 'm4a'; extension = 'm4a';
} }
} }
final mediaBaseDir = final mediaBaseDir = buildDirectoryPath(
buildDirectoryPath(directory, globalApplicationSupportDirectory); directory,
globalApplicationSupportDirectory,
);
return File( return File(
join(mediaBaseDir.path, '${mediaFile.mediaId}$namePrefix.$extension'), join(mediaBaseDir.path, '${mediaFile.mediaId}$namePrefix.$extension'),
); );
@ -303,29 +317,29 @@ class MediaFileService {
File get tempPath => _buildFilePath('tmp'); File get tempPath => _buildFilePath('tmp');
File get storedPath => _buildFilePath('stored'); File get storedPath => _buildFilePath('stored');
File get thumbnailPath => _buildFilePath( File get thumbnailPath => _buildFilePath(
'stored', 'stored',
namePrefix: '.thumbnail', namePrefix: '.thumbnail',
extensionParam: 'webp', extensionParam: 'webp',
); );
File get encryptedPath => _buildFilePath( File get encryptedPath => _buildFilePath(
'tmp', 'tmp',
namePrefix: '.encrypted', namePrefix: '.encrypted',
); );
File get uploadRequestPath => _buildFilePath( File get uploadRequestPath => _buildFilePath(
'tmp', 'tmp',
namePrefix: '.upload', namePrefix: '.upload',
); );
File get originalPath => _buildFilePath( File get originalPath => _buildFilePath(
'tmp', 'tmp',
namePrefix: '.original', namePrefix: '.original',
); );
File get ffmpegOutputPath => _buildFilePath( File get ffmpegOutputPath => _buildFilePath(
'tmp', 'tmp',
namePrefix: '.ffmpeg', namePrefix: '.ffmpeg',
); );
File get overlayImagePath => _buildFilePath( File get overlayImagePath => _buildFilePath(
'tmp', 'tmp',
namePrefix: '.overlay', namePrefix: '.overlay',
extensionParam: 'png', extensionParam: 'png',
); );
} }

View file

@ -57,12 +57,10 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
widget.mediaService.mediaFile.displayLimitInMilliseconds != null) { widget.mediaService.mediaFile.displayLimitInMilliseconds != null) {
return; return;
} }
if (widget.mediaService.tempPath.existsSync()) { if (widget.mediaService.tempPath.existsSync() && mounted) {
if (mounted) { setState(() {
setState(() { _canBeReopened = true;
_canBeReopened = true; });
});
}
} }
} }
@ -70,7 +68,7 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
if (widget.message.openedAt == null || widget.message.mediaStored) { if (widget.message.openedAt == null || widget.message.mediaStored) {
return; return;
} }
if (widget.mediaService.tempPath.existsSync() && if (widget.mediaService.canBeOpenedAgain &&
widget.message.senderId != null) { widget.message.senderId != null) {
await sendCipherText( await sendCipherText(
widget.message.senderId!, widget.message.senderId!,
@ -123,8 +121,14 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
final addData = widget.message.additionalMessageData; final addData = widget.message.additionalMessageData;
if (addData != null) { if (addData != null) {
final info = final info = getBubbleInfo(
getBubbleInfo(context, widget.message, null, null, null, 200); context,
widget.message,
null,
null,
null,
200,
);
final data = AdditionalMessageData.fromBuffer(addData); final data = AdditionalMessageData.fromBuffer(addData);
if (data.hasLink() && widget.message.mediaStored) { if (data.hasLink() && widget.message.mediaStored) {
imageBorderRadius = const BorderRadius.only( imageBorderRadius = const BorderRadius.only(
@ -138,8 +142,12 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8, maxWidth: MediaQuery.of(context).size.width * 0.8,
), ),
padding: padding: const EdgeInsets.only(
const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10), left: 10,
top: 6,
bottom: 6,
right: 10,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: info.color, color: info.color,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
@ -170,7 +178,8 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
onTap: (widget.message.type == MessageType.media.name) ? onTap : null, onTap: (widget.message.type == MessageType.media.name) ? onTap : null,
child: SizedBox( child: SizedBox(
width: (widget.minWidth > 150) ? widget.minWidth : 150, width: (widget.minWidth > 150) ? widget.minWidth : 150,
height: (widget.message.mediaStored && height:
(widget.message.mediaStored &&
widget.mediaService.imagePreviewAvailable) widget.mediaService.imagePreviewAvailable)
? 271 ? 271
: null, : null,

View file

@ -1,6 +1,7 @@
// ignore_for_file: inference_failure_on_function_invocation // ignore_for_file: inference_failure_on_function_invocation
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value;
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -39,57 +40,67 @@ class MessageContextMenu extends StatelessWidget {
final VoidCallback onResponseTriggered; final VoidCallback onResponseTriggered;
Future<void> reopenMediaFile(BuildContext context) async { Future<void> reopenMediaFile(BuildContext context) async {
final isAuth = await authenticateUser( if (message.senderId == null) {
context.lang.authRequestReopenImage, final isAuth = await authenticateUser(
force: false, context.lang.authRequestReopenImage,
); force: false,
);
if (!isAuth) return;
}
if (isAuth && context.mounted && mediaFileService != null) { if (!context.mounted || mediaFileService == null) return;
final galleryItems = [
MemoryItem(mediaService: mediaFileService!, messages: []),
];
await Navigator.push( if (message.senderId != null) {
context, // notify the sender
PageRouteBuilder( await sendCipherText(
opaque: false, message.senderId!,
pageBuilder: (context, a1, a2) => MemoriesPhotoSliderView( pb.EncryptedContent(
galleryItems: galleryItems, mediaUpdate: pb.EncryptedContent_MediaUpdate(
type: pb.EncryptedContent_MediaUpdate_Type.REOPENED,
targetMessageId: message.messageId,
), ),
), ),
); );
await twonlyDB.messagesDao.updateMessageId(
message.messageId,
const MessagesCompanion(openedAt: Value(null)),
);
return;
} }
if (!context.mounted) return;
final galleryItems = [
MemoryItem(mediaService: mediaFileService!, messages: []),
];
await Navigator.push(
context,
PageRouteBuilder(
opaque: false,
pageBuilder: (context, a1, a2) => MemoriesPhotoSliderView(
galleryItems: galleryItems,
),
),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var canBeOpenedAgain = false;
// in case this is a media send from this user...
if (mediaFileService != null && message.senderId == null) {
// and the media was send with unlimited display limit time and without auth required...
if (!mediaFileService!.mediaFile.requiresAuthentication &&
mediaFileService!.mediaFile.displayLimitInMilliseconds == null) {
// and the temp media file still exists
if (mediaFileService!.tempPath.existsSync()) {
// the media file can be opened again...
canBeOpenedAgain = true;
}
}
}
return ContextMenu( return ContextMenu(
items: [ items: [
if (!message.isDeletedFromSender) if (!message.isDeletedFromSender)
ContextMenuItem( ContextMenuItem(
title: context.lang.react, title: context.lang.react,
onTap: () async { onTap: () async {
final layer = await showModalBottomSheet( final layer =
context: context, await showModalBottomSheet(
backgroundColor: Colors.black, context: context,
builder: (context) { backgroundColor: Colors.black,
return const EmojiPickerBottom(); builder: (context) {
}, return const EmojiPickerBottom();
) as EmojiLayerData?; },
)
as EmojiLayerData?;
if (layer == null) return; if (layer == null) return;
await twonlyDB.reactionsDao.updateMyReaction( await twonlyDB.reactionsDao.updateMyReaction(
@ -111,7 +122,7 @@ class MessageContextMenu extends StatelessWidget {
}, },
icon: FontAwesomeIcons.faceLaugh, icon: FontAwesomeIcons.faceLaugh,
), ),
if (canBeOpenedAgain) if (mediaFileService?.canBeOpenedAgain ?? false)
ContextMenuItem( ContextMenuItem(
title: context.lang.contextMenuViewAgain, title: context.lang.contextMenuViewAgain,
onTap: () => reopenMediaFile(context), onTap: () => reopenMediaFile(context),
@ -153,8 +164,8 @@ class MessageContextMenu extends StatelessWidget {
null, null,
customOk: customOk:
(message.senderId == null && !message.isDeletedFromSender) (message.senderId == null && !message.isDeletedFromSender)
? context.lang.deleteOkBtnForAll ? context.lang.deleteOkBtnForAll
: context.lang.deleteOkBtnForMe, : context.lang.deleteOkBtnForMe,
); );
if (delete) { if (delete) {
if (message.senderId == null && !message.isDeletedFromSender) { if (message.senderId == null && !message.isDeletedFromSender) {
@ -173,8 +184,9 @@ class MessageContextMenu extends StatelessWidget {
), ),
); );
} else { } else {
await twonlyDB.messagesDao await twonlyDB.messagesDao.deleteMessagesById(
.deleteMessagesById(message.messageId); message.messageId,
);
} }
} }
}, },