From 5a7d61c96423d23154034ccee6176f3211b18cf7 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 9 Jan 2023 18:30:26 +0000 Subject: [PATCH 01/10] android: Live messages without sending an empty text (#1709) * android: Live messages without sending an empty text * Better quoted messages handling * Do not add item into preview * Change * Changes --- .../java/chat/simplex/app/model/ChatModel.kt | 44 ++++++++++++++++++- .../chat/simplex/app/views/TerminalView.kt | 4 +- .../chat/simplex/app/views/chat/ChatView.kt | 2 +- .../simplex/app/views/chat/ComposeView.kt | 40 +++++++++++++---- .../simplex/app/views/chat/SendMsgView.kt | 27 ++++++++++-- .../app/src/main/res/values/strings.xml | 1 + 6 files changed, 102 insertions(+), 16 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index e3dcc4911b..8d3f901e78 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -180,7 +180,11 @@ class ChatModel(val controller: ChatController) { } // add to current chat if (chatId.value == cInfo.id) { - chatItems.add(cItem) + if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem) + } else { + chatItems.add(cItem) + } } } @@ -255,6 +259,21 @@ class ChatModel(val controller: ChatController) { } } + fun addLiveChatItemDummy(quotedCItem: ChatItem?, chatInfo: ChatInfo): ChatItem { + val quoted = if (quotedCItem?.content?.msgContent != null) { + CIQuote(chatDir = quotedCItem.chatDir, itemId = quotedCItem.id, sentAt = quotedCItem.meta.createdAt, content = quotedCItem.content.msgContent!!) + } else null + val cItem = ChatItem.liveChatItemDummy(chatInfo is ChatInfo.Direct, quoted) + chatItems.add(cItem) + return cItem + } + + fun removeLiveChatItemDummy() { + if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + chatItems.removeLast() + } + } + fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { val markedRead = markItemsReadInCurrentChat(cInfo, range) // update preview @@ -1278,7 +1297,8 @@ data class ChatItem ( } private const val TEMP_DELETED_CHAT_ITEM_ID = -1L - + const val TEMP_LIVE_CHAT_ITEM_ID = -2L + val deletedItemDummy: ChatItem get() = ChatItem( chatDir = CIDirection.DirectRcv(), @@ -1300,6 +1320,26 @@ data class ChatItem ( file = null ) + fun liveChatItemDummy(direct: Boolean, quoted: CIQuote?): ChatItem = ChatItem( + chatDir = if (direct) CIDirection.DirectSnd() else CIDirection.GroupSnd(), + meta = CIMeta( + itemId = TEMP_LIVE_CHAT_ITEM_ID, + itemTs = Clock.System.now(), + itemText = "", + itemStatus = CIStatus.RcvRead(), + createdAt = Clock.System.now(), + updatedAt = Clock.System.now(), + itemDeleted = false, + itemEdited = false, + itemTimed = null, + itemLive = true, + editable = false + ), + content = CIContent.SndMsgContent(MsgContent.MCText("")), + quotedItem = quoted, + file = null + ) + fun invalidJSON(json: String): ChatItem = ChatItem( chatDir = CIDirection.DirectSnd(), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index acfb205d38..feed70ce75 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -147,8 +147,8 @@ fun TerminalLayout( sendMessage = sendCommand, sendLiveMessage = null, updateLiveMessage = null, - ::onMessageChange, - textStyle + onMessageChange = ::onMessageChange, + textStyle = textStyle ) } }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index e774bb9420..7d15b5ff67 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -568,7 +568,7 @@ fun BoxWithConstraintsScope.ChatItemsList( scope.launch { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { + } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index e4581931f7..0409046066 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -41,6 +41,7 @@ import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable @@ -67,7 +68,8 @@ sealed class ComposeContextItem { data class LiveMessage( val chatItem: ChatItem, val typedMsg: String, - val sentMsg: String + val sentMsg: String, + val sent: Boolean ) @Serializable @@ -352,6 +354,7 @@ fun ComposeView( chosenContent.value = emptyList() chosenAudio.value = null chosenFile.value = null + chatModel.removeLiveChatItemDummy() } suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? { @@ -430,7 +433,7 @@ fun ComposeView( if (cs.contextItem is ComposeContextItem.EditingItem) { val ei = cs.contextItem.chatItem sent = updateMessage(ei, cInfo, live) - } else if (liveMessage != null) { + } else if (liveMessage != null && liveMessage.sent) { sent = updateMessage(liveMessage.chatItem, cInfo, live) } else { val msgs: ArrayList = ArrayList() @@ -571,11 +574,14 @@ fun ComposeView( suspend fun sendLiveMessage() { val typedMsg = composeState.value.message val sentMsg = truncateToWords(typedMsg) - if (composeState.value.liveMessage == null) { + if (sentMsg.isNotEmpty() && (composeState.value.liveMessage == null || composeState.value.liveMessage?.sent == false)) { val ci = sendMessageAsync(sentMsg, live = true) if (ci != null) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg)) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) } + } else if (composeState.value.liveMessage == null) { + val cItem = chatModel.addLiveChatItemDummy((composeState.value.contextItem as? ComposeContextItem.QuotedItem)?.chatItem, chat.chatInfo) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = sentMsg, sent = false)) } } @@ -592,7 +598,7 @@ fun ComposeView( if (sentMsg != null) { val ci = sendMessageAsync(sentMsg, live = true) if (ci != null) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg)) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) } } else if (liveMessage.typedMsg != typedMsg) { composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) @@ -696,14 +702,28 @@ fun ComposeView( } } } + LaunchedEffect(Unit) { + snapshotFlow { composeState.value.contextItem } + .distinctUntilChanged() + .collect { + if (composeState.value.liveMessage?.sent == false) { + chatModel.removeLiveChatItemDummy() + chatModel.addLiveChatItemDummy((it as? ComposeContextItem.QuotedItem)?.chatItem, chat.chatInfo) + } + } + } val activity = LocalContext.current as Activity DisposableEffect(Unit) { val orientation = activity.resources.configuration.orientation onDispose { - if (orientation == activity.resources.configuration.orientation && composeState.value.liveMessage != null) { - sendMessage() - resetLinkPreview() + if (orientation == activity.resources.configuration.orientation) { + val cs = composeState.value + if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage() + resetLinkPreview() + } + chatModel.removeLiveChatItemDummy() } } } @@ -723,6 +743,10 @@ fun ComposeView( }, sendLiveMessage = ::sendLiveMessage, updateLiveMessage = ::updateLiveMessage, + cancelLiveMessage = { + composeState.value = composeState.value.copy(liveMessage = null) + chatModel.removeLiveChatItemDummy() + }, onMessageChange = ::onMessageChange, textStyle = textStyle ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 1bf9b70383..379cc36b44 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat.getSystemService import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat @@ -65,6 +64,7 @@ fun SendMsgView( sendMessage: () -> Unit, sendLiveMessage: ( suspend () -> Unit)? = null, updateLiveMessage: (suspend () -> Unit)? = null, + cancelLiveMessage: (() -> Unit)? = null, onMessageChange: (String) -> Unit, textStyle: MutableState ) { @@ -119,9 +119,14 @@ fun SendMsgView( } } } + cs.liveMessage?.sent == false && cs.message.isEmpty() -> { + CancelLiveMessageButton { + cancelLiveMessage?.invoke() + } + } else -> { val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward - val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight + val color = if (cs.sendEnabled() && cs.message.isNotEmpty()) MaterialTheme.colors.primary else HighOrLowlight if (composeState.value.liveMessage == null && cs.preview !is ComposePreview.VoicePreview && !cs.editing && sendLiveMessage != null && updateLiveMessage != null @@ -144,7 +149,7 @@ fun SendMsgView( ) } } else { - SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) + SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled() && cs.message.isNotEmpty(), sendMessage) } } } @@ -373,6 +378,22 @@ private fun ProgressIndicator() { CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp) } +@Composable +private fun CancelLiveMessageButton( + onClick: () -> Unit +) { + IconButton(onClick, Modifier.size(36.dp)) { + Icon( + Icons.Filled.Close, + stringResource(R.string.icon_descr_cancel_live_message), + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(36.dp) + .padding(4.dp) + ) + } +} + @Composable private fun SendTextButton( icon: ImageVector, diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 7fd343c223..e40198c80a 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -271,6 +271,7 @@ Live message! Send a live message - it will update for the recipient(s) as you type it Send + Cancel live message Back From 813fecddfe8d73e29f7365156b9c77837f618321 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 10 Jan 2023 16:22:21 +0400 Subject: [PATCH 02/10] core: fix live file transfers queries (#1715) --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Store.hs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b708e293a9..8ed7ab6641 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1737,7 +1737,7 @@ subscribeUserConnections agentBatchSubscribe user = do pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> m () pendingConnSubsToView rs = toView . CRPendingSubSummary . map (uncurry PendingSubStatus) . resultsFor rs withStore_ :: (DB.Connection -> User -> IO [a]) -> m [a] - withStore_ a = withStore' (`a` user) `catchError` \_ -> pure [] + withStore_ a = withStore' (`a` user) `catchError` \e -> toView (CRChatError e) >> pure [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)] diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 1fc311f282..fd5e27b773 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1267,7 +1267,7 @@ getLiveSndFileTransfers db User {userId} = do FROM files f JOIN snd_files s USING (file_id) WHERE f.user_id = ? AND s.file_status IN (?, ?, ?) AND s.file_inline IS NULL - AND created_at > ? + AND s.created_at > ? |] (userId, FSNew, FSAccepted, FSConnected, cutoffTs) concatMap (filter liveTransfer) . rights <$> mapM (getSndFileTransfers_ db userId) fileIds @@ -1287,7 +1287,7 @@ getLiveRcvFileTransfers db user@User {userId} = do FROM files f JOIN rcv_files r USING (file_id) WHERE f.user_id = ? AND r.file_status IN (?, ?) AND r.rcv_file_inline IS NULL - AND created_at > ? + AND r.created_at > ? |] (userId, FSAccepted, FSConnected, cutoffTs) rights <$> mapM (runExceptT . getRcvFileTransfer db user) fileIds From 810f248c746a25bd33760b3aba01d111f94b7b57 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 10 Jan 2023 20:52:59 +0400 Subject: [PATCH 03/10] core: test async file transfer (sender & receiver restarts); close files in stopChatController; handle openFile error in getFileHandle (#1716) --- src/Simplex/Chat.hs | 15 +++++++++++---- tests/ChatTests.hs | 32 +++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8ed7ab6641..161928875b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -227,13 +227,21 @@ restoreCalls user = do calls <- asks currentCalls atomically $ writeTVar calls callsMap -stopChatController :: MonadUnliftIO m => ChatController -> m () -stopChatController ChatController {smpAgent, agentAsync = s, expireCIs} = do +stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIs} = do disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) + closeFiles sndFiles + closeFiles rcvFiles atomically $ do writeTVar expireCIs False writeTVar s Nothing + where + closeFiles :: TVar (Map Int64 Handle) -> m () + closeFiles files = do + fs <- readTVarIO files + mapM_ hClose fs + atomically $ writeTVar files M.empty execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse execChatCommand s = case parseChatCommand s of @@ -3252,8 +3260,7 @@ getFileHandle fileId filePath files ioMode = do maybe (newHandle fs) pure h_ where newHandle fs = do - -- TODO handle errors - h <- liftIO (openFile filePath ioMode) + h <- liftIO (openFile filePath ioMode) `E.catch` (throwChatError . CEFileInternal . (show :: E.SomeException -> String)) atomically . modifyTVar fs $ M.insert fileId h pure h diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 1987d97507..13ef6d7aa2 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -159,10 +159,12 @@ chatTests = do -- it "v1 to v2" testFullAsyncV1toV2 -- it "v2 to v1" testFullAsyncV2toV1 describe "async sending and receiving files" $ do + it "send and receive file, sender restarts" testAsyncFileTransferSenderRestarts + it "send and receive file, receiver restarts" testAsyncFileTransferReceiverRestarts xdescribe "send and receive file, fully asynchronous" $ do it "v2" testAsyncFileTransfer it "v1" testAsyncFileTransferV1 - xit "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer + it "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer describe "webrtc calls api" $ do it "negotiate call" testNegotiateCall describe "maintenance mode" $ do @@ -4010,6 +4012,34 @@ testFullAsyncV2toV1 = withTmpFiles $ do withNewBob = withNewTestChat "bob" bobProfile withBob = withTestChat "bob" +testAsyncFileTransferSenderRestarts :: IO () +testAsyncFileTransferSenderRestarts = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChat "alice" aliceProfile $ \alice -> do + connectUsers alice bob + startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" + threadDelay 100000 + withTestChatContactConnected "alice" $ \alice -> do + alice <## "completed sending file 1 (test_1MB.pdf) to bob" + bob <## "completed receiving file 1 (test_1MB.pdf) from alice" + src <- B.readFile "./tests/fixtures/test_1MB.pdf" + dest <- B.readFile "./tests/tmp/test_1MB.pdf" + dest `shouldBe` src + +testAsyncFileTransferReceiverRestarts :: IO () +testAsyncFileTransferReceiverRestarts = withTmpFiles $ do + withNewTestChat "alice" aliceProfile $ \alice -> do + withNewTestChat "bob" bobProfile $ \bob -> do + connectUsers alice bob + startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes" + threadDelay 100000 + withTestChatContactConnected "bob" $ \bob -> do + alice <## "completed sending file 1 (test_1MB.pdf) to bob" + bob <## "completed receiving file 1 (test_1MB.pdf) from alice" + src <- B.readFile "./tests/fixtures/test_1MB.pdf" + dest <- B.readFile "./tests/tmp/test_1MB.pdf" + dest `shouldBe` src + testAsyncFileTransfer :: IO () testAsyncFileTransfer = withTmpFiles $ do withNewTestChat "alice" aliceProfile $ \alice -> From 16b041c8c6aeb775e9173ce10ab4303545ffda08 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 10 Jan 2023 19:12:48 +0000 Subject: [PATCH 04/10] ios: Live messages without sending an empty text (#1714) * ios: Live messages without sending an empty text * Custom Equatable * Changes * Change * Fix liveMessage not hiding * Refactoring * Refactoring * No animation when removing dummy live message item * Check * Anim * Animation * whitespace * refactor * Fix race * Better fix of race * fix race condition Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 39 ++++++++++++++++++- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Chat/ComposeMessage/ComposeView.swift | 31 +++++++++++---- .../Chat/ComposeMessage/SendMessageView.swift | 33 ++++++++++++++-- apps/ios/SimpleXChat/ChatTypes.swift | 28 +++++++++++++ 5 files changed, 118 insertions(+), 15 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d55c7d5a37..ef3a6f8f32 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -219,7 +219,7 @@ final class ChatModel: ObservableObject { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { let ci = reversedChatItems[i] - withAnimation(.default) { + withAnimation { self.reversedChatItems[i] = cItem self.reversedChatItems[i].viewTimestamp = .now // on some occasions the confirmation of message being accepted by the server (tick) @@ -230,9 +230,18 @@ final class ChatModel: ObservableObject { } return false } else { - withAnimation { reversedChatItems.insert(cItem, at: 0) } + withAnimation(itemAnimation()) { + reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) + } return true } + + func itemAnimation() -> Animation? { + switch cItem.chatDir { + case .directSnd, .groupSnd: return cItem.meta.isLive ? nil : .default + default: return .default + } + } } func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { @@ -274,6 +283,32 @@ final class ChatModel: ObservableObject { return nil } + func addLiveDummy(_ quotedCItem: ChatItem?, _ chatInfo: ChatInfo) -> ChatItem { + var quoted: CIQuote? = nil + if let quotedItem = quotedCItem, let msgContent = quotedItem.content.msgContent { + quoted = CIQuote.getSampleWithMsgContent(itemId: quotedItem.id, sentAt: quotedItem.meta.updatedAt, msgContent: msgContent, chatDir: quotedItem.chatDir) + } + let cItem = ChatItem.liveChatItemDummy(chatInfo.chatType, quoted) + withAnimation { + reversedChatItems.insert(cItem, at: 0) + } + return cItem + } + + func removeLiveDummy(animated: Bool = true) { + if hasLiveDummy { + if animated { + withAnimation { _ = reversedChatItems.removeFirst() } + } else { + _ = reversedChatItems.removeFirst() + } + } + } + + private var hasLiveDummy: Bool { + reversedChatItems.first?.isLiveDummy == true + } + func markChatItemsRead(_ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index affd9fe797..9ecf04c011 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -462,7 +462,7 @@ struct ChatView: View { private func menu() -> [UIAction] { var menu: [UIAction] = [] if let mc = ci.content.msgContent, !ci.meta.itemDeleted || revealed { - if !ci.meta.itemDeleted { + if !ci.meta.itemDeleted && !ci.isLiveDummy && composeState.liveMessage == nil { menu.append(replyUIAction()) } menu.append(shareUIAction()) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index fff6f5a178..ab8297cfc8 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -34,7 +34,7 @@ enum VoiceMessageRecordingState { struct LiveMessage { var chatItem: ChatItem var typedMsg: String - var sentMsg: String + var sentMsg: String? } struct ComposeState { @@ -232,9 +232,9 @@ struct ComposeView: View { VStack(spacing: 0) { contextItemView() switch (composeState.editing, composeState.preview) { - case (true, .filePreview): EmptyView() - case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed - default: previewView() + case (true, .filePreview): EmptyView() + case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed + default: previewView() } HStack (alignment: .bottom) { Button { @@ -255,6 +255,10 @@ struct ComposeView: View { }, sendLiveMessage: sendLiveMessage, updateLiveMessage: updateLiveMessage, + cancelLiveMessage: { + composeState.liveMessage = nil + chatModel.removeLiveDummy() + }, voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, startVoiceMessageRecording: { @@ -371,10 +375,11 @@ struct ComposeView: View { if let fileName = composeState.voiceMessageRecordingFileName { cancelVoiceMessageRecording(fileName) } - if composeState.liveMessage != nil { + if composeState.liveMessage != nil && (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) { sendMessage() resetLinkPreview() } + chatModel.removeLiveDummy(animated: false) } .onChange(of: chatModel.stopPreviousRecPlay) { _ in if !startingRecording { @@ -396,11 +401,20 @@ struct ComposeView: View { private func sendLiveMessage() async { let typedMsg = composeState.message let sentMsg = truncateToWords(typedMsg) - if composeState.liveMessage == nil, + if !sentMsg.isEmpty && (composeState.liveMessage == nil || composeState.liveMessage?.sentMsg == nil), let ci = await sendMessageAsync(sentMsg, live: true) { await MainActor.run { composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg)) } + } else if composeState.liveMessage == nil { + var quoted: ChatItem? = nil + if case let .quotedItem(item) = composeState.contextItem { + quoted = item + } + let cItem = chatModel.addLiveDummy(quoted, chat.chatInfo) + await MainActor.run { + composeState = composeState.copy(liveMessage: LiveMessage(chatItem: cItem, typedMsg: typedMsg, sentMsg: nil)) + } } } @@ -424,7 +438,7 @@ struct ComposeView: View { private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? { let s = t != lm.typedMsg ? truncateToWords(t) : t - return s != lm.sentMsg ? s : nil + return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil } private func truncateToWords(_ s: String) -> String { @@ -512,7 +526,7 @@ struct ComposeView: View { } if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) - } else if let liveMessage = liveMessage { + } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { sent = await updateMessage(liveMessage.chatItem, live: live) } else { var quoted: Int64? = nil @@ -609,6 +623,7 @@ struct ComposeView: View { live: live ) { await MainActor.run { + chatModel.removeLiveDummy(animated: false) chatModel.addChatItem(chat.chatInfo, chatItem) } return chatItem diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 7995c8be41..5e85140a05 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -9,11 +9,14 @@ import SwiftUI import SimpleXChat +private let liveMsgInterval: UInt64 = 3000_000000 + struct SendMessageView: View { @Binding var composeState: ComposeState var sendMessage: () -> Void var sendLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil + var cancelLiveMessage: (() -> Void)? = nil var showVoiceMessageButton: Bool = true var voiceMessageAllowed: Bool = true var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other @@ -103,6 +106,10 @@ struct SendMessageView: View { } } else if vmrs == .recording && !holdingVMR { finishVoiceMessageRecordingButton() + } else if composeState.liveMessage != nil && composeState.liveMessage?.sentMsg == nil && composeState.message.isEmpty { + cancelLiveMessageButton { + cancelLiveMessage?() + } } else { sendMessageButton() } @@ -129,7 +136,8 @@ struct SendMessageView: View { .disabled( !composeState.sendEnabled || composeState.disabled || - (!voiceMessageAllowed && composeState.voicePreview) + (!voiceMessageAllowed && composeState.voicePreview) || + (composeState.liveMessage != nil && composeState.message.isEmpty) ) .frame(width: 29, height: 29) @@ -220,6 +228,20 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 4) } + private func cancelLiveMessageButton(cancel: @escaping () -> Void) -> some View { + return Button { + cancel() + } label: { + Image(systemName: "multiply") + .resizable() + .scaledToFit() + .foregroundColor(.accentColor) + .frame(width: 15, height: 15) + } + .frame(width: 29, height: 29) + .padding([.bottom, .horizontal], 4) + } + private func startLiveMessageButton(send: @escaping () async -> Void, update: @escaping () async -> Void) -> some View { return Button { switch composeState.preview { @@ -271,9 +293,12 @@ struct SendMessageView: View { sendButtonOpacity = 1 } } - Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { t in - if composeState.liveMessage == nil { t.invalidate() } - Task { await update() } + Task { + _ = try? await Task.sleep(nanoseconds: liveMsgInterval) + while composeState.liveMessage != nil { + await update() + _ = try? await Task.sleep(nanoseconds: liveMsgInterval) + } } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index abf7a15640..9f46ba3912 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1670,6 +1670,7 @@ public struct ChatItem: Identifiable, Decodable { public var file: CIFile? public var viewTimestamp = Date.now + public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { case chatDir, meta, content, formattedText, quotedItem, file @@ -1862,6 +1863,29 @@ public struct ChatItem: Identifiable, Decodable { ) } + public static func liveChatItemDummy(_ chatType: ChatType, _ quoted: CIQuote?) -> ChatItem { + var item = ChatItem( + chatDir: chatType == ChatType.direct ? CIDirection.directSnd : CIDirection.groupSnd, + meta: CIMeta( + itemId: -2, + itemTs: .now, + itemText: "", + itemStatus: .rcvRead, + createdAt: .now, + updatedAt: .now, + itemDeleted: false, + itemEdited: false, + itemLive: true, + editable: false + ), + content: .sndMsgContent(msgContent: .text("")), + quotedItem: quoted, + file: nil + ) + item.isLiveDummy = true + return item + } + public static func invalidJSON(_ json: String) -> ChatItem { ChatItem( chatDir: CIDirection.directSnd, @@ -2111,6 +2135,10 @@ public struct CIQuote: Decodable, ItemContent { } return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) } + + public static func getSampleWithMsgContent(itemId: Int64?, sentAt: Date, msgContent: MsgContent, chatDir: CIDirection) -> CIQuote { + return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: msgContent) + } } public struct CIFile: Decodable { From d1cc5c17691aa07ef14bd2ef702b5aac06610e4e Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 10 Jan 2023 19:39:42 +0000 Subject: [PATCH 05/10] ios: Better check for existing of image's alpha (#1718) * ios: Better check for existing of image's alpha * Allow non-transparent pixels * optimize * remove prints Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ImageUtils.swift | 56 +++++++++++++++++--------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift index 6de6dd024f..79382e4d6d 100644 --- a/apps/ios/Shared/Model/ImageUtils.swift +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -49,8 +49,9 @@ func saveAnimImage(_ image: UIImage) -> String? { } func saveImage(_ uiImage: UIImage) -> String? { - if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE) { - let ext = imageHasAlpha(uiImage) ? "png" : "jpg" + let hasAlpha = imageHasAlpha(uiImage) + let ext = hasAlpha ? "png" : "jpg" + if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) { let fileName = generateNewFileName("IMG", ext) return saveFile(imageDataResized, fileName) } @@ -67,19 +68,18 @@ func cropToSquare(_ image: UIImage) -> UIImage { } else if size.height > side { origin.y -= (size.height - side) / 2 } - return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size)) + return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image)) } -func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? { +func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? { var img = image - let usePng = imageHasAlpha(image) - var data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85) + var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) var dataSize = data?.count ?? 0 while dataSize != 0 && dataSize > maxDataSize { let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio) - data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85) + img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha) + data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) dataSize = data?.count ?? 0 } logger.debug("resizeImageToDataSize final \(dataSize)") @@ -88,45 +88,61 @@ func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? { func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? { var img = image - var str = compressImageStr(img) + let hasAlpha = imageHasAlpha(image) + var str = compressImageStr(img, hasAlpha: hasAlpha) var dataSize = str?.count ?? 0 while dataSize != 0 && dataSize > maxDataSize { let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) let clippedRatio = min(ratio, 2.0) - img = reduceSize(img, ratio: clippedRatio) - str = compressImageStr(img) + img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha) + str = compressImageStr(img, hasAlpha: hasAlpha) dataSize = str?.count ?? 0 } logger.debug("resizeImageToStrSize final \(dataSize)") return str } -func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { - let ext = imageHasAlpha(image) ? "png" : "jpg" - if let data = imageHasAlpha(image) ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { +func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? { + let ext = hasAlpha ? "png" : "jpg" + if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { return "data:image/\(ext);base64,\(data.base64EncodedString())" } return nil } -private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage { +private func reduceSize(_ image: UIImage, ratio: CGFloat, hasAlpha: Bool) -> UIImage { let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio)) let bounds = CGRect(origin: .zero, size: newSize) - return resizeImage(image, newBounds: bounds, drawIn: bounds) + return resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha) } -private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage { +private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = 1.0 - format.opaque = !imageHasAlpha(image) + format.opaque = !hasAlpha return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in image.draw(in: drawIn) } } func imageHasAlpha(_ img: UIImage) -> Bool { - let alpha = img.cgImage?.alphaInfo - return alpha == .first || alpha == .last || alpha == .premultipliedFirst || alpha == .premultipliedLast || alpha == .alphaOnly + if let cgImage = img.cgImage { + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) + if let context = CGContext(data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) { + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) + if let data = context.data { + let data = data.assumingMemoryBound(to: UInt8.self) + let size = cgImage.width * cgImage.height + var i = 0 + while i < size { + if data[i] < 255 { return true } + i += 4 + } + } + } + } + return false } func saveFileFromURL(_ url: URL) -> String? { From 61e20550bc2eb2b3ad1bc704db043e7b8c5bc7ce Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 10 Jan 2023 20:22:18 +0000 Subject: [PATCH 06/10] core: Updated scripts for downloading libs (#1712) --- scripts/android/download_libs_aarch64.sh | 6 +++--- scripts/ios/download_libs.sh | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/android/download_libs_aarch64.sh b/scripts/android/download_libs_aarch64.sh index 07541c69ca..d54a6fd23d 100755 --- a/scripts/android/download_libs_aarch64.sh +++ b/scripts/android/download_libs_aarch64.sh @@ -7,7 +7,7 @@ function readlink() { } if [ -z ${1} ]; then - echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something" + echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something/{master,stable}" exit 1 fi @@ -22,12 +22,12 @@ output_dir="$root_dir/apps/android/app/src/main/cpp/libs/$output_arch/" mkdir -p "$output_dir" 2> /dev/null -curl --location -o libsupport.zip $job_repo/simplex-chat/$arch-android:lib:support.x86_64-linux/latest/download/1 && \ +curl --location -o libsupport.zip $job_repo/$arch-android:lib:support.x86_64-linux/latest/download/1 && \ unzip -o libsupport.zip && \ mv libsupport.so "$output_dir" && \ rm libsupport.zip -curl --location -o libsimplex.zip $job_repo/simplex-chat/$arch-android:lib:simplex-chat.x86_64-linux/latest/download/1 && \ +curl --location -o libsimplex.zip $job_repo/$arch-android:lib:simplex-chat.x86_64-linux/latest/download/1 && \ unzip -o libsimplex.zip && \ mv libsimplex.so "$output_dir" && \ rm libsimplex.zip diff --git a/scripts/ios/download_libs.sh b/scripts/ios/download_libs.sh index fa50a5b124..b6d253a9c4 100755 --- a/scripts/ios/download_libs.sh +++ b/scripts/ios/download_libs.sh @@ -7,7 +7,7 @@ function readlink() { } if [ -z ${1} ]; then - echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something" + echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something/{master,stable}" exit 1 fi @@ -15,10 +15,10 @@ job_repo=$1 root_dir="$(dirname $(dirname $(readlink $0)))" -curl --location -o ~/Downloads/pkg-ios-aarch64-swift-json.zip $job_repo/simplex-chat/aarch64-darwin-ios:lib:simplex-chat.aarch64-darwin/latest/download/1 && \ +curl --location -o ~/Downloads/pkg-ios-aarch64-swift-json.zip $job_repo/aarch64-darwin-ios:lib:simplex-chat.aarch64-darwin/latest/download/1 && \ unzip -o ~/Downloads/pkg-ios-aarch64-swift-json.zip -d ~/Downloads/pkg-ios-aarch64-swift-json -curl --location -o ~/Downloads/pkg-ios-x86_64-swift-json.zip $job_repo/simplex-chat/x86_64-darwin-ios:lib:simplex-chat.x86_64-darwin/latest/download/1 && \ +curl --location -o ~/Downloads/pkg-ios-x86_64-swift-json.zip $job_repo/x86_64-darwin-ios:lib:simplex-chat.x86_64-darwin/latest/download/1 && \ unzip -o ~/Downloads/pkg-ios-x86_64-swift-json.zip -d ~/Downloads/pkg-ios-x86_64-swift-json sh $root_dir/scripts/ios/prepare-x86_64.sh From 13ebaf587edb3d436dff1a479492f697443da90c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 10 Jan 2023 23:16:44 +0000 Subject: [PATCH 07/10] 4.4.1-beta.1: iOS 112, Android 86 --- apps/android/app/build.gradle | 4 ++-- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 ++++---- package.yaml | 2 +- simplex-chat.cabal | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 2920ba6980..d0f012c6aa 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 85 - versionName "4.4.1-beta.0" + versionCode 86 + versionName "4.4.1-beta.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 1d46f24ed0..97ba41184c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1305,7 +1305,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 111; + CURRENT_PROJECT_VERSION = 112; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1347,7 +1347,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 111; + CURRENT_PROJECT_VERSION = 112; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1426,7 +1426,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 111; + CURRENT_PROJECT_VERSION = 112; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1456,7 +1456,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 111; + CURRENT_PROJECT_VERSION = 112; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; diff --git a/package.yaml b/package.yaml index c78393562c..725f901fb4 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 4.4.0 +version: 4.4.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b8bf55e631..93c734e06a 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 4.4.0 +version: 4.4.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 9e3573fc765f34c5d5b8c746068eeb88ce3beadb Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 11 Jan 2023 12:59:04 +0400 Subject: [PATCH 08/10] android: fix send button being disabled on images, files & voice messages (#1720) * android: fix send button being disabled on images, files & voice messages * rename view * format --- .../chat/simplex/app/views/chat/SendMsgView.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 379cc36b44..5063233342 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -62,7 +62,7 @@ fun SendMsgView( allowedVoiceByPrefs: Boolean, allowVoiceToContact: () -> Unit, sendMessage: () -> Unit, - sendLiveMessage: ( suspend () -> Unit)? = null, + sendLiveMessage: (suspend () -> Unit)? = null, updateLiveMessage: (suspend () -> Unit)? = null, cancelLiveMessage: (() -> Unit)? = null, onMessageChange: (String) -> Unit, @@ -70,7 +70,7 @@ fun SendMsgView( ) { Box(Modifier.padding(vertical = 8.dp)) { val cs = composeState.value - val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) + val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) NativeKeyboard(composeState, textStyle, onMessageChange) @@ -126,13 +126,13 @@ fun SendMsgView( } else -> { val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward - val color = if (cs.sendEnabled() && cs.message.isNotEmpty()) MaterialTheme.colors.primary else HighOrLowlight + val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight if (composeState.value.liveMessage == null && cs.preview !is ComposePreview.VoicePreview && !cs.editing && sendLiveMessage != null && updateLiveMessage != null ) { var showDropdown by rememberSaveable { mutableStateOf(false) } - SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true } + SendMsgButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true } DropdownMenu( expanded = showDropdown, @@ -149,7 +149,7 @@ fun SendMsgView( ) } } else { - SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled() && cs.message.isNotEmpty(), sendMessage) + SendMsgButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) } } } @@ -171,7 +171,6 @@ private fun NativeKeyboard( val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() } val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() } val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() } - var showKeyboard by remember { mutableStateOf(false) } LaunchedEffect(cs.contextItem) { if (cs.contextItem is ComposeContextItem.QuotedItem) { @@ -192,6 +191,7 @@ private fun NativeKeyboard( ) { super.setOnReceiveContentListener(mimeTypes, listener) } + override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { val connection = super.onCreateInputConnection(editorInfo) EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) @@ -344,7 +344,6 @@ private fun LockToCurrentOrientationUntilDispose() { } } - @Composable private fun StopRecordButton(onClick: () -> Unit) { IconButton(onClick, Modifier.size(36.dp)) { @@ -395,7 +394,7 @@ private fun CancelLiveMessageButton( } @Composable -private fun SendTextButton( +private fun SendMsgButton( icon: ImageVector, backgroundColor: Color, sizeDp: Animatable, @@ -573,7 +572,7 @@ fun PreviewSendMsgViewEditing() { SendMsgView( composeState = remember { mutableStateOf(composeStateEditing) }, showVoiceRecordIcon = false, - recState = remember { mutableStateOf(RecordingState.NotStarted) }, + recState = remember { mutableStateOf(RecordingState.NotStarted) }, isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), needToAllowVoiceToContact = false, From a6d7604d217881918a2d132f5362af553c58bae9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Jan 2023 12:01:02 +0000 Subject: [PATCH 09/10] mobile: send live message when there is any content (#1721) * ios: send live message when there is any content * android: improve live message logic * fix, refactor * prohibit live messages with quotes --- .../java/chat/simplex/app/model/ChatModel.kt | 13 +++---- .../simplex/app/views/chat/ComposeView.kt | 36 ++++++++----------- .../simplex/app/views/chat/SendMsgView.kt | 20 +++++++---- .../app/views/chat/item/ChatItemView.kt | 9 +++-- apps/ios/Shared/Model/ChatModel.swift | 8 ++--- .../Chat/ComposeMessage/ComposeView.swift | 28 +++++++++------ .../Chat/ComposeMessage/SendMessageView.swift | 7 ++-- apps/ios/SimpleXChat/ChatTypes.swift | 8 ++--- 8 files changed, 65 insertions(+), 64 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 8d3f901e78..2e689f24cd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -259,16 +259,13 @@ class ChatModel(val controller: ChatController) { } } - fun addLiveChatItemDummy(quotedCItem: ChatItem?, chatInfo: ChatInfo): ChatItem { - val quoted = if (quotedCItem?.content?.msgContent != null) { - CIQuote(chatDir = quotedCItem.chatDir, itemId = quotedCItem.id, sentAt = quotedCItem.meta.createdAt, content = quotedCItem.content.msgContent!!) - } else null - val cItem = ChatItem.liveChatItemDummy(chatInfo is ChatInfo.Direct, quoted) + fun addLiveDummy(chatInfo: ChatInfo): ChatItem { + val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) chatItems.add(cItem) return cItem } - fun removeLiveChatItemDummy() { + fun removeLiveDummy() { if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { chatItems.removeLast() } @@ -1320,7 +1317,7 @@ data class ChatItem ( file = null ) - fun liveChatItemDummy(direct: Boolean, quoted: CIQuote?): ChatItem = ChatItem( + fun liveDummy(direct: Boolean): ChatItem = ChatItem( chatDir = if (direct) CIDirection.DirectSnd() else CIDirection.GroupSnd(), meta = CIMeta( itemId = TEMP_LIVE_CHAT_ITEM_ID, @@ -1336,7 +1333,7 @@ data class ChatItem ( editable = false ), content = CIContent.SndMsgContent(MsgContent.MCText("")), - quotedItem = quoted, + quotedItem = null, file = null ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 0409046066..7d712a8a7b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -41,7 +41,6 @@ import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable @@ -105,6 +104,9 @@ data class ComposeState( } hasContent && !inProgress } + val endLiveDisabled: Boolean + get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + val linkPreviewAllowed: Boolean get() = when (preview) { @@ -354,7 +356,7 @@ fun ComposeView( chosenContent.value = emptyList() chosenAudio.value = null chosenFile.value = null - chatModel.removeLiveChatItemDummy() + chatModel.removeLiveDummy() } suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? { @@ -572,16 +574,16 @@ fun ComposeView( } suspend fun sendLiveMessage() { - val typedMsg = composeState.value.message - val sentMsg = truncateToWords(typedMsg) - if (sentMsg.isNotEmpty() && (composeState.value.liveMessage == null || composeState.value.liveMessage?.sent == false)) { - val ci = sendMessageAsync(sentMsg, live = true) + val cs = composeState.value + val typedMsg = cs.message + if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) { + val ci = sendMessageAsync(typedMsg, live = true) if (ci != null) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true)) } - } else if (composeState.value.liveMessage == null) { - val cItem = chatModel.addLiveChatItemDummy((composeState.value.contextItem as? ComposeContextItem.QuotedItem)?.chatItem, chat.chatInfo) - composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = sentMsg, sent = false)) + } else if (cs.liveMessage == null) { + val cItem = chatModel.addLiveDummy(chat.chatInfo) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false)) } } @@ -702,16 +704,6 @@ fun ComposeView( } } } - LaunchedEffect(Unit) { - snapshotFlow { composeState.value.contextItem } - .distinctUntilChanged() - .collect { - if (composeState.value.liveMessage?.sent == false) { - chatModel.removeLiveChatItemDummy() - chatModel.addLiveChatItemDummy((it as? ComposeContextItem.QuotedItem)?.chatItem, chat.chatInfo) - } - } - } val activity = LocalContext.current as Activity DisposableEffect(Unit) { @@ -723,7 +715,7 @@ fun ComposeView( sendMessage() resetLinkPreview() } - chatModel.removeLiveChatItemDummy() + chatModel.removeLiveDummy() } } } @@ -745,7 +737,7 @@ fun ComposeView( updateLiveMessage = ::updateLiveMessage, cancelLiveMessage = { composeState.value = composeState.value.copy(liveMessage = null) - chatModel.removeLiveChatItemDummy() + chatModel.removeLiveDummy() }, onMessageChange = ::onMessageChange, textStyle = textStyle diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 5063233342..e84af43f0b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -109,7 +109,10 @@ fun SendMsgView( else -> RecordVoiceView(recState, stopRecOnNextClick) } - if (sendLiveMessage != null && updateLiveMessage != null && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)) { + if (sendLiveMessage != null + && updateLiveMessage != null + && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) + && cs.contextItem is ComposeContextItem.NoContextItem) { Spacer(Modifier.width(10.dp)) StartLiveMessageButton { if (composeState.value.preview is ComposePreview.NoPreview) { @@ -125,14 +128,18 @@ fun SendMsgView( } } else -> { + val cs = composeState.value val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward - val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight - if (composeState.value.liveMessage == null && + val disabled = !cs.sendEnabled() || + (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || + cs.endLiveDisabled + if (cs.liveMessage == null && cs.preview !is ComposePreview.VoicePreview && !cs.editing && + cs.contextItem is ComposeContextItem.NoContextItem && sendLiveMessage != null && updateLiveMessage != null ) { var showDropdown by rememberSaveable { mutableStateOf(false) } - SendMsgButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true } + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown = true } DropdownMenu( expanded = showDropdown, @@ -149,7 +156,7 @@ fun SendMsgView( ) } } else { - SendMsgButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) } } } @@ -396,7 +403,6 @@ private fun CancelLiveMessageButton( @Composable private fun SendMsgButton( icon: ImageVector, - backgroundColor: Color, sizeDp: Animatable, alpha: Animatable, enabled: Boolean, @@ -425,7 +431,7 @@ private fun SendMsgButton( .padding(4.dp) .alpha(alpha.value) .clip(CircleShape) - .background(backgroundColor) + .background(if (enabled) MaterialTheme.colors.primary else HighOrLowlight) .padding(3.dp) ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 1eb4f21cee..4749390646 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -54,6 +54,7 @@ fun ChatItemView( val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file) val onLinkLongClick = { _: String -> showMenu.value = true } + val live = composeState.value.liveMessage != null Box( modifier = Modifier @@ -97,7 +98,7 @@ fun ChatItemView( onDismissRequest = { showMenu.value = false }, Modifier.width(220.dp) ) { - if (!cItem.meta.itemDeleted) { + if (!cItem.meta.itemDeleted && !live) { ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) @@ -133,7 +134,7 @@ fun ChatItemView( }) } } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) { + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) showMenu.value = false @@ -149,7 +150,9 @@ fun ChatItemView( } ) } - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + if (!(live && cItem.meta.isLive)) { + DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + } } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index ef3a6f8f32..5a15c4946a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -283,12 +283,8 @@ final class ChatModel: ObservableObject { return nil } - func addLiveDummy(_ quotedCItem: ChatItem?, _ chatInfo: ChatInfo) -> ChatItem { - var quoted: CIQuote? = nil - if let quotedItem = quotedCItem, let msgContent = quotedItem.content.msgContent { - quoted = CIQuote.getSampleWithMsgContent(itemId: quotedItem.id, sentAt: quotedItem.meta.updatedAt, msgContent: msgContent, chatDir: quotedItem.chatDir) - } - let cItem = ChatItem.liveChatItemDummy(chatInfo.chatType, quoted) + func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem { + let cItem = ChatItem.liveDummy(chatInfo.chatType) withAnimation { reversedChatItems.insert(cItem, at: 0) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index ab8297cfc8..5ca4627917 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -96,6 +96,13 @@ struct ComposeState { } } + var quoting: Bool { + switch contextItem { + case .quotedItem: return true + default: return false + } + } + var sendEnabled: Bool { switch preview { case .imagePreviews: return true @@ -105,6 +112,10 @@ struct ComposeState { } } + var endLiveDisabled: Bool { + liveMessage != nil && message.isEmpty && noPreview && !quoting + } + var linkPreviewAllowed: Bool { switch preview { case .imagePreviews: return false @@ -400,18 +411,15 @@ struct ComposeView: View { private func sendLiveMessage() async { let typedMsg = composeState.message - let sentMsg = truncateToWords(typedMsg) - if !sentMsg.isEmpty && (composeState.liveMessage == nil || composeState.liveMessage?.sentMsg == nil), - let ci = await sendMessageAsync(sentMsg, live: true) { + let lm = composeState.liveMessage + if (composeState.sendEnabled || composeState.quoting) + && (lm == nil || lm?.sentMsg == nil), + let ci = await sendMessageAsync(typedMsg, live: true) { await MainActor.run { - composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg)) + composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: typedMsg)) } - } else if composeState.liveMessage == nil { - var quoted: ChatItem? = nil - if case let .quotedItem(item) = composeState.contextItem { - quoted = item - } - let cItem = chatModel.addLiveDummy(quoted, chat.chatInfo) + } else if lm == nil { + let cItem = chatModel.addLiveDummy(chat.chatInfo) await MainActor.run { composeState = composeState.copy(liveMessage: LiveMessage(chatItem: cItem, typedMsg: typedMsg, sentMsg: nil)) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 5e85140a05..b5f8e9b5fd 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -100,7 +100,9 @@ struct SendMessageView: View { } else { voiceMessageNotAllowedButton() } - if let send = sendLiveMessage, let update = updateLiveMessage { + if let send = sendLiveMessage, + let update = updateLiveMessage, + case .noContextItem = composeState.contextItem { startLiveMessageButton(send: send, update: update) } } @@ -137,11 +139,12 @@ struct SendMessageView: View { !composeState.sendEnabled || composeState.disabled || (!voiceMessageAllowed && composeState.voicePreview) || - (composeState.liveMessage != nil && composeState.message.isEmpty) + composeState.endLiveDisabled ) .frame(width: 29, height: 29) if composeState.liveMessage == nil, + case .noContextItem = composeState.contextItem, !composeState.voicePreview && !composeState.editing, let send = sendLiveMessage, let update = updateLiveMessage { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9f46ba3912..dae0bbe08d 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1863,7 +1863,7 @@ public struct ChatItem: Identifiable, Decodable { ) } - public static func liveChatItemDummy(_ chatType: ChatType, _ quoted: CIQuote?) -> ChatItem { + public static func liveDummy(_ chatType: ChatType) -> ChatItem { var item = ChatItem( chatDir: chatType == ChatType.direct ? CIDirection.directSnd : CIDirection.groupSnd, meta: CIMeta( @@ -1879,7 +1879,7 @@ public struct ChatItem: Identifiable, Decodable { editable: false ), content: .sndMsgContent(msgContent: .text("")), - quotedItem: quoted, + quotedItem: nil, file: nil ) item.isLiveDummy = true @@ -2135,10 +2135,6 @@ public struct CIQuote: Decodable, ItemContent { } return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) } - - public static func getSampleWithMsgContent(itemId: Int64?, sentAt: Date, msgContent: MsgContent, chatDir: CIDirection) -> CIQuote { - return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: msgContent) - } } public struct CIFile: Decodable { From 2d47175f94cd34c347002911183eb8d1e0e4f3ab Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Jan 2023 13:29:09 +0000 Subject: [PATCH 10/10] ios: disable reply/edit actions and deletion of live item in live mode (#1722) --- apps/ios/Shared/Views/Chat/ChatView.swift | 18 ++++++++++++------ .../ios/Shared/Views/Helpers/ContextMenu.swift | 6 +++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 9ecf04c011..fd94e96a33 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -442,9 +442,13 @@ struct ChatView: View { var body: some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - + let uiMenu: Binding = Binding( + get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) }, + set: { _ in } + ) + ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed) - .uiKitContextMenu(actions: menu()) + .uiKitContextMenu(menu: uiMenu) .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal) @@ -459,10 +463,10 @@ struct ChatView: View { .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) } - private func menu() -> [UIAction] { + private func menu(live: Bool) -> [UIAction] { var menu: [UIAction] = [] if let mc = ci.content.msgContent, !ci.meta.itemDeleted || revealed { - if !ci.meta.itemDeleted && !ci.isLiveDummy && composeState.liveMessage == nil { + if !ci.meta.itemDeleted && !ci.isLiveDummy && !live { menu.append(replyUIAction()) } menu.append(shareUIAction()) @@ -478,13 +482,15 @@ struct ChatView: View { menu.append(saveFileAction(filePath)) } } - if ci.meta.editable && !mc.isVoice { + if ci.meta.editable && !mc.isVoice && !live { menu.append(editAction()) } if revealed { menu.append(hideUIAction()) } - menu.append(deleteUIAction()) + if !live || !ci.meta.isLive { + menu.append(deleteUIAction()) + } } else if ci.meta.itemDeleted { menu.append(revealUIAction()) menu.append(deleteUIAction()) diff --git a/apps/ios/Shared/Views/Helpers/ContextMenu.swift b/apps/ios/Shared/Views/Helpers/ContextMenu.swift index e2342c274b..4efc114032 100644 --- a/apps/ios/Shared/Views/Helpers/ContextMenu.swift +++ b/apps/ios/Shared/Views/Helpers/ContextMenu.swift @@ -11,10 +11,10 @@ import UIKit import SwiftUI extension View { - func uiKitContextMenu(title: String = "", actions: [UIAction]) -> some View { + func uiKitContextMenu(menu: Binding) -> some View { self.overlay(Color(uiColor: .systemBackground)) .overlay( - InteractionView(content: self, menu: UIMenu(title: title, children: actions)) + InteractionView(content: self, menu: menu) ) } } @@ -26,7 +26,7 @@ private struct InteractionConfig { private struct InteractionView: UIViewRepresentable { let content: Content - let menu: UIMenu + @Binding var menu: UIMenu func makeUIView(context: Context) -> UIView { let view = UIView()