From 7d870c6ff515d97ed93b81d2964ffd9f310d82f4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 11:24:14 +0100 Subject: [PATCH] ui: open file after tap when it is received (#7700) * ui: open file after tap when it is received * fix ios * simplify * simplify * better --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 17 +++++ apps/ios/Shared/Model/SimpleXAPI.swift | 3 + .../Views/Chat/ChatItem/CIFileView.swift | 7 +- .../Views/Chat/ChatItem/CIImageView.swift | 1 + .../Views/Chat/ChatItem/CIVideoView.swift | 20 ++++++ .../Views/Chat/ChatItem/CIVoiceView.swift | 5 ++ .../chat/simplex/common/model/ChatModel.kt | 15 +++++ .../common/views/chat/item/CIFileView.kt | 24 +++++-- .../common/views/chat/item/CIImageView.kt | 10 +++ .../common/views/chat/item/CIVideoView.kt | 66 +++++++++++-------- .../common/views/chat/item/CIVoiceView.kt | 3 +- 11 files changed, 132 insertions(+), 39 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 7c1e8e65ab..1ee79e1c4f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -474,10 +474,27 @@ final class ChatModel: ObservableObject { // another in its place; only the badge alert shows regardless. Set while rendering, so not published. var chatListBanner: ChatListBanner? + var fileToOpen: (fileId: Int64, chatId: String?, open: (CIFile) -> Void)? + func bannerSlotFree(for banner: ChatListBanner) -> Bool { chatListBanner == nil || chatListBanner == banner } + func openWhenReceived(_ file: CIFile, open: @escaping (CIFile) -> Void) { + fileToOpen = (fileId: file.fileId, chatId: chatId, open: open) + } + + func receivedFileToOpen(_ file: CIFile) -> Bool { + if let f = fileToOpen { f.fileId == file.fileId && f.chatId == chatId } else { false } + } + + func openReceivedFile(_ file: CIFile) { + if let f = fileToOpen, receivedFileToOpen(file) { + fileToOpen = nil + f.open(file) + } + } + static let shared = ChatModel() let im = ItemsModel.shared diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d04ccf5b03..3ec9c34f8e 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2873,6 +2873,9 @@ func processReceivedMsg(_ res: ChatEvent) async { await chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): await chatItemSimpleUpdate(user, aChatItem) + if active(user), let file = aChatItem.chatItem.file, m.receivedFileToOpen(file) { + await MainActor.run { m.openReceivedFile(file) } + } case let .rcvFileSndCancelled(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 8bb76ca155..59ab1c24e0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -26,7 +26,7 @@ struct CIFileView: View { var body: some View { if smallViewSize != nil { fileIndicator() - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) } else { // reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil } @@ -56,7 +56,7 @@ struct CIFileView: View { .padding(.bottom, 6) .padding(.leading, 10) .padding(.trailing, 12) - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) .disabled(!itemInteractive) } } @@ -85,7 +85,7 @@ struct CIFileView: View { return false } - private func fileAction() { + private func fileAction(_ file: CIFile?) { logger.debug("CIFileView fileAction") if let file = file { switch (file.fileStatus) { @@ -93,6 +93,7 @@ struct CIFileView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { fileAction($0) } Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index fb6ccb1ea3..469fc5a3fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -54,6 +54,7 @@ struct CIImageView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { _ in showFullScreenImage = true } Task { if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 0df17dfe3d..339d119362 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -120,6 +120,25 @@ struct CIVideoView: View { } } + private func playReceived(_ file: CIFile) { + setupPlayer(file) + if urlDecrypted == nil { + decrypt(file: file, completed: playVideo) + } else { + playVideo() + } + } + + private func playVideo() { + if smallView { + showFullScreenPlayer = urlDecrypted != nil + } else if let urlDecrypted { + m.stopPreviousRecPlay = urlDecrypted + videoPlaying = true + player?.play() + } + } + private func setupPlayer(_ file: CIFile?) { let newUrl = getLoadedVideo(file) if newUrl == url { @@ -424,6 +443,7 @@ struct CIVideoView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file, open: playReceived) Task { if let user = m.currentUser { await receiveFile(user, file.fileId, false, false) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 64fa2a4dd3..6c89a5f788 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -303,6 +303,11 @@ struct VoiceMessagePlayer: View { private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { playPauseIcon(icon, theme.colors.primary) .simultaneousGesture(TapGesture().onEnded { + chatModel.openWhenReceived(recordingFile) { file in + if let recordingSource = getLoadedFileSource(file) { + startPlayback(recordingSource) + } + } Task { if let user = chatModel.currentUser { await receiveFile(user: user, fileId: recordingFile.fileId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index c33a077ae9..1afea4541b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -132,6 +132,8 @@ object BadgeModel { enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake } +data class FileToOpen(val fileId: Long, val inChatList: Boolean) + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -205,8 +207,21 @@ object ChatModel { // another in its place; only the badge alert shows regardless. Set while rendering, so not a state. var chatListBanner: ChatListBanner? = null + var fileToOpen: FileToOpen? = null + fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner + fun openWhenReceived(file: CIFile, inChatList: Boolean) { + fileToOpen = FileToOpen(file.fileId, inChatList) + } + + fun receivedFileToOpen(file: CIFile, inChatList: Boolean): Boolean { + val f = fileToOpen + if (f == null || f.fileId != file.fileId || f.inChatList != inChatList || !file.loaded) return false + fileToOpen = null + return !inChatList || appPlatform.isDesktop || chatId.value == null + } + // preferences val notificationPreviewMode by lazy { mutableStateOf( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index a3c2b63f8f..1523e2ff22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -80,6 +80,7 @@ fun CIFileView( if (prohibited != null) { showProhibitedFileAlert(file, prohibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } @@ -188,16 +189,18 @@ fun CIFileView( val encrypted = file.fileSource.cryptoArgs != null SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) } + val onClick: () -> Unit = { + if (appPlatform.isAndroid && loadedFilePath != null) { + showOpenSaveMenu.value = true + } else { + fileAction() + } + } + if (file != null) OpenWhenReceivedEffect(file, smallView, onClick) Row( Modifier .combinedClickable( - onClick = { - if (appPlatform.isAndroid && loadedFilePath != null) { - showOpenSaveMenu.value = true - } else { - fileAction() - } - }, + onClick = onClick, onLongClick = { showMenu.value = true } ) .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), @@ -239,6 +242,13 @@ fun CIFileView( // the core decides whether a received file is above the size the sender's badge allows fun fileSizeValid(file: CIFile): Boolean = file.fileProhibited == null +@Composable +fun OpenWhenReceivedEffect(file: CIFile, inChatList: Boolean, open: () -> Unit) { + KeyChangeEffect(file.loaded) { + if (chatModel.receivedFileToOpen(file, inChatList)) open() + } +} + fun showProhibitedFileAlert(file: CIFile, prohibited: FileProhibited) { val badgeIssue = when (prohibited.badgeStatus) { null, BadgeStatus.Active -> "" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index d5ea44126b..a49884beeb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -173,6 +173,15 @@ fun CIImageView( return null } + val view = LocalMultiplatformView() + if (file != null) { + OpenWhenReceivedEffect(file, smallView) { + hideKeyboard(view) + ModalManager.fullscreen.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } + } + } Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) .then( @@ -222,6 +231,7 @@ fun CIImageView( if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } file.fileStatus is CIFileStatus.RcvAccepted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 21f406db18..62e1b9f737 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -70,6 +70,8 @@ fun CIVideoView( } } val f = filePath.value + val autoPlay = remember { mutableStateOf(false) } + if (file != null) OpenWhenReceivedEffect(file, smallView) { autoPlay.value = true } if (file != null && f != null) { val view = LocalMultiplatformView() val openFullscreen = { @@ -80,7 +82,6 @@ fun CIVideoView( } val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) } - val autoPlay = remember { mutableStateOf(false) } val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) } val decrypted = uriDecrypted.value if (decrypted != null && smallView) { @@ -98,7 +99,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, smallView, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -128,7 +129,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, smallView, receiveFile) } } } } @@ -156,22 +157,26 @@ private fun VideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = false, blurred = blurred, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(1f, onLongClick = onLongClick) } else if (!blurred.value) { - PlayButton(false, 1f, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, 1f, onLongClick = onLongClick, onClick = play) } DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(defaultDuration) }, remember { mutableStateOf(0L) }) } @@ -190,22 +195,26 @@ private fun SmallVideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick) } else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) { - PlayButton(false, sizeMultiplier, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, sizeMultiplier, onLongClick = onLongClick, onClick = play) } } } @@ -240,7 +249,7 @@ private fun SmallVideoView( PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen) } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) openFullscreen() } } @@ -272,7 +281,7 @@ private fun VideoView( player.stop() } val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) play() } // Drop autoPlay only when show preview changes to prevent blinking of the view @@ -566,10 +575,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { +private fun receiveFileIfValidSize(file: CIFile, smallView: Boolean, receiveFile: (Long) -> Unit) { if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 3b3c835e0a..5aeefd9668 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -87,7 +87,7 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, receiveFile) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) { AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } if (smallView) { @@ -95,6 +95,7 @@ fun CIVoiceView( AudioPlayer.stop() } } + OpenWhenReceivedEffect(file, smallView, play) } else if (smallView) { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, sizeMultiplier, {}, {}, longClick, receiveFile) } else {