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>
This commit is contained in:
Evgeny
2026-10-13 11:24:14 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat
parent 1bd7c95006
commit 7d870c6ff5
11 changed files with 132 additions and 39 deletions
+17
View File
@@ -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
+3
View File
@@ -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) }
@@ -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 {
@@ -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)
@@ -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)
@@ -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)
@@ -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(
@@ -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 -> ""
@@ -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 ->
@@ -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)
}
}
@@ -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 {