diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 3b10bcad55..ed724599be 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -165,7 +165,7 @@ struct FramedItemView: View { ) } - @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = true) -> some View { + @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View { let v = HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 7a1299c612..6ce582cad4 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -89,11 +89,12 @@ class MainActivity: FragmentActivity() { } override fun onBackPressed() { - if ( - onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack - || Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above - || isTaskRoot // there are still other tasks after we reach the main (home) activity - ) { + val canFinishActivity = ( + onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack + || Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above + || isTaskRoot // there are still other tasks after we reach the main (home) activity + ) && SimplexApp.context.chatModel.sharedContent.value !is SharedContent.Forward + if (canFinishActivity) { // https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06 super.onBackPressed() } @@ -104,9 +105,15 @@ class MainActivity: FragmentActivity() { AppLock.laFailed.value = true } if (!onBackPressedDispatcher.hasEnabledCallbacks()) { + val sharedContent = chatModel.sharedContent.value // Drop shared content - SimplexApp.context.chatModel.sharedContent.value = null - finish() + chatModel.sharedContent.value = null + if (sharedContent is SharedContent.Forward) { + chatModel.chatId.value = sharedContent.fromChatInfo.id + } + if (canFinishActivity) { + finish() + } } } } 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 d0a02c332f..ca9056a058 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 @@ -111,7 +111,7 @@ object ChatModel { var draft = mutableStateOf(null as ComposeState?) var draftChatId = mutableStateOf(null as String?) - // working with external intents + // working with external intents or internal forwarding of chat items val sharedContent = mutableStateOf(null as SharedContent?) val filesToDelete = mutableSetOf() @@ -1753,7 +1753,7 @@ data class ChatItem ( val allowAddReaction: Boolean get() = meta.itemDeleted == null && !isLiveDummy && (reactions.count { it.userReacted } < 3) - private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID + val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null @@ -2298,8 +2298,26 @@ enum class MsgDirection { @Serializable sealed class CIForwardedFrom { @Serializable @SerialName("unknown") object Unknown: CIForwardedFrom() - @Serializable @SerialName("contact") class Contact(val chatName: String, val msgDir: MsgDirection, val contactId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() - @Serializable @SerialName("group") class Group(val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() + @Serializable @SerialName("contact") class Contact(override val chatName: String, val msgDir: MsgDirection, val contactId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() + @Serializable @SerialName("group") class Group(override val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() + + open val chatName: String + get() = when (this) { + Unknown -> "" + is Contact -> chatName + is Group -> chatName + } + + fun text(chatType: ChatType): String = + if (chatType == ChatType.Local) { + if (chatName.isEmpty()) { + generalGetString(MR.strings.saved_description) + } else { + generalGetString(MR.strings.saved_from_description).format(chatName) + } + } else { + generalGetString(MR.strings.forwarded_description) + } } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 4e239598fb..90d6995504 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -738,12 +738,16 @@ object ChatController { suspend fun apiSendMessage(rh: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) + return processSendMessageCmd(rh, cmd) + } + + private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): AChatItem? { val r = sendCmd(rh, cmd) return when (r) { is CR.NewChatItem -> r.chatItem else -> { if (!(networkErrorAlert(r))) { - apiErrorAlert("apiSendMessage", generalGetString(MR.strings.error_sending_message), r) + apiErrorAlert("processSendMessageCmd", generalGetString(MR.strings.error_sending_message), r) } null } @@ -771,6 +775,13 @@ object ChatController { } } + suspend fun apiForwardChatItem(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemId: Long): ChatItem? { + val cmd = CC.ApiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId) + return processSendMessageCmd(rh, cmd)?.chatItem + } + + + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) if (r is CR.ChatItemUpdated) return r.chatItem @@ -1971,7 +1982,6 @@ object ChatController { } is CR.SndFileCompleteXFTP -> { chatItemSimpleUpdate(rhId, r.user, r.chatItem) - cleanupFile(r.chatItem) } is CR.SndFileError -> { if (r.chatItem_ != null) { @@ -2396,6 +2406,7 @@ sealed class CC { class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() + class ApiForwardChatItem(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemId: Long): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() @@ -2539,6 +2550,7 @@ sealed class CC { is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" + is ApiForwardChatItem -> "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} $itemId" is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" @@ -2677,6 +2689,7 @@ sealed class CC { is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" is ApiChatItemReaction -> "apiChatItemReaction" + is ApiForwardChatItem -> "apiForwardChatItem" is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 14bb3543c3..df8e535f82 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -13,9 +13,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.* import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -29,6 +28,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -36,10 +36,11 @@ sealed class CIInfoTab { class Delivery(val memberDeliveryStatuses: List): CIInfoTab() object History: CIInfoTab() class Quote(val quotedItem: CIQuote): CIInfoTab() + class Forwarded(val forwardedFromChatItem: AChatItem): CIInfoTab() } @Composable -fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { +fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { val sent = ci.chatDir.sent val appColors = CurrentColors.collectAsState().value.appColors val uriHandler = LocalUriHandler.current @@ -151,6 +152,70 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d } } + val local = when (ci.chatDir) { + is CIDirection.LocalSnd -> true + is CIDirection.LocalRcv -> true + else -> false + } + + @Composable + fun ForwardedFromSender(forwardedFromItem: AChatItem) { + @Composable + fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) { + Text( + text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body1, + fontStyle = fontStyle, + color = color, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + ChatInfoImage(forwardedFromItem.chatInfo, size = 57.dp) + Column( + modifier = Modifier + .padding(start = 15.dp) + .weight(1F) + ) { + if (forwardedFromItem.chatItem.chatDir.sent) { + ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic) + Spacer(Modifier.height(7.dp)) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + } else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) { + ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName) + Spacer(Modifier.height(7.dp)) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + } else { + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground) + } + } + } + } + + @Composable + fun ForwardedFromView(forwardedFromItem: AChatItem) { + Column { + SectionItemView( + click = { + withBGApi { + openChat(chatRh, forwardedFromItem.chatInfo, chatModel) + ModalManager.end.closeModals() + } + }, + padding = PaddingValues(start = 17.dp, end = DEFAULT_PADDING) + ) { + ForwardedFromSender(forwardedFromItem) + } + + if (!local) { + Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 41.dp, end = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING_HALF)) + Text(stringResource(MR.strings.recipients_can_not_see_who_message_from), Modifier.padding(horizontal = DEFAULT_PADDING), fontSize = 12.sp, color = MaterialTheme.colors.secondary) + } + } + } + @Composable fun Details() { AppBarTitle(stringResource(if (ci.localNote) MR.strings.saved_message_title else if (sent) MR.strings.sent_message else MR.strings.received_message)) @@ -188,7 +253,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d // LALAL SCROLLBAR DOESN'T WORK ColumnWithScrollBar(Modifier.fillMaxWidth()) { Details() - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true) val versions = ciInfo.itemVersions if (versions.isNotEmpty()) { SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { @@ -213,7 +278,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d // LALAL SCROLLBAR DOESN'T WORK ColumnWithScrollBar(Modifier.fillMaxWidth()) { Details() - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true) SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(stringResource(MR.strings.in_reply_to), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) QuotedMsgView(qi) @@ -222,6 +287,22 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d } } + @Composable + fun ForwardedFromTab(forwardedFromItem: AChatItem) { + // LALAL SCROLLBAR DOESN'T WORK + ColumnWithScrollBar(Modifier.fillMaxWidth()) { + Details() + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true) + SectionView { + Text(stringResource(if (local) MR.strings.saved_from_chat_item_info_title else MR.strings.forwarded_from_chat_item_info_title), + style = MaterialTheme.typography.h2, + modifier = Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)) + ForwardedFromView(forwardedFromItem) + } + SectionBottomSpacer() + } + } + @Composable fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus) { SectionItemView( @@ -271,7 +352,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d // LALAL SCROLLBAR DOESN'T WORK ColumnWithScrollBar(Modifier.fillMaxWidth()) { Details() - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true) val mss = membersStatuses(chatModel, memberDeliveryStatuses) if (mss.isNotEmpty()) { SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { @@ -297,6 +378,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d is CIInfoTab.Delivery -> stringResource(MR.strings.delivery) is CIInfoTab.History -> stringResource(MR.strings.edit_history) is CIInfoTab.Quote -> stringResource(MR.strings.in_reply_to) + is CIInfoTab.Forwarded -> stringResource(if (local) MR.strings.saved_chat_item_info_tab else MR.strings.forwarded_chat_item_info_tab) } } @@ -305,6 +387,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d is CIInfoTab.Delivery -> MR.images.ic_double_check is CIInfoTab.History -> MR.images.ic_history is CIInfoTab.Quote -> MR.images.ic_reply + is CIInfoTab.Forwarded -> MR.images.ic_forward } } @@ -316,6 +399,9 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d if (ci.quotedItem != null) { numTabs += 1 } + if (ciInfo.forwardedFromChatItem != null) { + numTabs += 1 + } return numTabs } @@ -326,11 +412,6 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d .fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween ) { - LaunchedEffect(ciInfo) { - if (ciInfo.memberDeliveryStatuses != null) { - selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses) - } - } Column(Modifier.weight(1f)) { when (val sel = selection.value) { is CIInfoTab.Delivery -> { @@ -344,6 +425,10 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d is CIInfoTab.Quote -> { QuoteTab(sel.quotedItem) } + + is CIInfoTab.Forwarded -> { + ForwardedFromTab(sel.forwardedFromChatItem) + } } } val availableTabs = mutableListOf() @@ -354,6 +439,19 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d if (ci.quotedItem != null) { availableTabs.add(CIInfoTab.Quote(ci.quotedItem)) } + if (ciInfo.forwardedFromChatItem != null) { + availableTabs.add(CIInfoTab.Forwarded(ciInfo.forwardedFromChatItem)) + } + if (availableTabs.none { it.javaClass == selection.value.javaClass }) { + selection.value = availableTabs.first() + } + LaunchedEffect(ciInfo) { + if (ciInfo.forwardedFromChatItem != null && selection.value is CIInfoTab.Forwarded) { + selection.value = CIInfoTab.Forwarded(ciInfo.forwardedFromChatItem) + } else if (ciInfo.memberDeliveryStatuses != null) { + selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses) + } + } TabRow( selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class }, backgroundColor = Color.Transparent, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 55ee46f1e2..0c9a973a69 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -52,9 +52,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { + val draft = chatModel.draft.value + val sharedContent = chatModel.sharedContent.value mutableStateOf( - if (chatModel.draftChatId.value == chatId && chatModel.draft.value != null) { - chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) + if (chatModel.draftChatId.value == chatId && draft != null && (sharedContent !is SharedContent.Forward || sharedContent.fromChatInfo.id == chatId)) { + draft } else { ComposeState(useLinkPreviews = useLinkPreviews) } @@ -408,7 +410,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) } }) { close -> - ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) KeyChangeEffect(chatModel.chatId.value) { close() } @@ -956,7 +958,7 @@ fun BoxWithConstraintsScope.ChatItemsList( tryOrShowError("${cItem.id}ChatItem", error = { CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) }) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemView(chat.remoteHostId, chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 1c8b32d467..9ab2b47702 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -46,6 +46,7 @@ sealed class ComposeContextItem { @Serializable object NoContextItem: ComposeContextItem() @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() + @Serializable class ForwardingItem(val chatItem: ChatItem, val fromChatInfo: ChatInfo): ComposeContextItem() } @Serializable @@ -79,13 +80,18 @@ data class ComposeState( is ComposeContextItem.EditingItem -> true else -> false } + val forwarding: Boolean + get() = when (contextItem) { + is ComposeContextItem.ForwardingItem -> true + else -> false + } val sendEnabled: () -> Boolean get() = { val hasContent = when (preview) { is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || liveMessage != null + else -> message.isNotEmpty() || forwarding || liveMessage != null } hasContent && !inProgress } @@ -109,7 +115,7 @@ data class ComposeState( val attachmentDisabled: Boolean get() { - if (editing || liveMessage != null || inProgress) return true + if (editing || forwarding || liveMessage != null || inProgress) return true return when (preview) { ComposePreview.NoPreview -> false is ComposePreview.CLinkPreview -> false @@ -355,6 +361,7 @@ fun ComposeView( is SharedContent.Media -> shared.uris.map { it.toString() } is SharedContent.File -> listOf(shared.uri.toString()) is SharedContent.Text -> emptyList() + is SharedContent.Forward -> emptyList() } // When sharing a file and pasting it in SimpleX itself, the file shouldn't be deleted before sending or before leaving the chat after sharing chatModel.filesToDelete.removeAll { file -> @@ -401,6 +408,21 @@ fun ComposeView( composeState.value = composeState.value.copy(inProgress = true) } + suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo): ChatItem? { + val chatItem = controller.apiForwardChatItem( + rh = rhId, + toChatType = chat.chatInfo.chatType, + toChatId = chat.chatInfo.apiId, + fromChatType = fromChatInfo.chatType, + fromChatId = fromChatInfo.apiId, + itemId = forwardedItem.id + ) + if (chatItem != null) { + chatModel.addChatItem(rhId, chat.chatInfo, chatItem) + } + return chatItem + } + fun checkLinkPreview(): MsgContent { return when (val composePreview = cs.preview) { is ComposePreview.CLinkPreview -> { @@ -460,11 +482,18 @@ fun ComposeView( if (liveMessage != null) composeState.value = cs.copy(liveMessage = null) sending() } - clearCurrentDraft() + if (!cs.forwarding || chatModel.draft.value?.forwarding == true) { + clearCurrentDraft() + } if (chat.nextSendGrpInv) { sendMemberContactInvitation() sent = null + } else if (cs.contextItem is ComposeContextItem.ForwardingItem) { + sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItem, cs.contextItem.fromChatInfo) + if (cs.message.isNotEmpty()) { + sent = send(chat, checkLinkPreview(), quoted = sent?.id, live = false, ttl = null) + } } else if (cs.contextItem is ComposeContextItem.EditingItem) { val ei = cs.contextItem.chatItem sent = updateMessage(ei, chat, live) @@ -563,7 +592,15 @@ fun ComposeView( sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } + val wasForwarding = cs.forwarding + val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItem)?.fromChatInfo?.id clearState(live) + val draft = chatModel.draft.value + if (wasForwarding && chatModel.draftChatId.value == chat.chatInfo.id && forwardingFromChatId != chat.chatInfo.id && draft != null) { + composeState.value = draft + } else { + clearCurrentDraft() + } return sent } @@ -745,6 +782,9 @@ fun ComposeView( is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_edit_filled)) { clearState() } + is ComposeContextItem.ForwardingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_forward), showSender = false) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) + } } } @@ -764,6 +804,10 @@ fun ComposeView( is SharedContent.Text -> onMessageChange(shared.text) is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text) is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text) + is SharedContent.Forward -> composeState.value = composeState.value.copy( + contextItem = ComposeContextItem.ForwardingItem(shared.chatItem, shared.fromChatInfo), + preview = if (composeState.value.preview is ComposePreview.CLinkPreview) composeState.value.preview else ComposePreview.NoPreview + ) null -> {} } chatModel.sharedContent.value = null @@ -903,6 +947,17 @@ fun ComposeView( chatModel.removeLiveDummy() CIFile.cachedRemoteFileRequests.clear() } + if (appPlatform.isDesktop) { + // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` + DisposableEffect(Unit) { + onDispose { + if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } + } + } + } val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } val sendButtonColor = diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index b53574cfa4..ce34ecf0c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -4,26 +4,29 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.ui.text.TextStyle +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.* +import androidx.compose.ui.text.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.model.* import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Clock @Composable fun ContextItemView( contextItem: ChatItem, contextIcon: Painter, + showSender: Boolean = true, cancelContextItem: () -> Unit ) { val sent = contextItem.chatDir.sent @@ -31,16 +34,47 @@ fun ContextItemView( val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage @Composable - fun msgContentView(lines: Int) { + fun MessageText(attachment: ImageResource?, lines: Int) { + val inlineContent: Pair Unit, Map>? = if (attachment != null) { + remember(contextItem.id) { + val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = { + appendInlineContent(id = "attachmentIcon") + append(" ") + } + val inlineContent = mapOf( + "attachmentIcon" to InlineTextContent( + Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(attachment), null, tint = MaterialTheme.colors.secondary) + } + ) + inlineContentBuilder to inlineContent + } + } else null MarkdownText( contextItem.text, contextItem.formattedText, + sender = null, toggleSecrets = false, maxLines = lines, + inlineContent = inlineContent, linkMode = SimplexLinkMode.DESCRIPTION, modifier = Modifier.fillMaxWidth(), ) } + fun attachment(): ImageResource? = + when (contextItem.content.msgContent) { + is MsgContent.MCFile -> MR.images.ic_draft_filled + is MsgContent.MCImage -> MR.images.ic_image + is MsgContent.MCVoice -> MR.images.ic_play_arrow_filled + else -> null + } + + @Composable + fun ContextMsgPreview(lines: Int) { + MessageText(remember(contextItem.id) { attachment() }, lines) + } + Row( Modifier .padding(top = 8.dp) @@ -64,7 +98,7 @@ fun ContextItemView( tint = MaterialTheme.colors.secondary, ) val sender = contextItem.memberDisplayName - if (sender != null) { + if (showSender && sender != null) { Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(4.dp), @@ -73,10 +107,10 @@ fun ContextItemView( sender, style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) ) - msgContentView(lines = 2) + ContextMsgPreview(lines = 2) } } else { - msgContentView(lines = 3) + ContextMsgPreview(lines = 3) } } IconButton(onClick = cancelContextItem) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 14bcf7dfb7..58705bd00a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -72,7 +72,7 @@ fun SendMsgView( } } val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && - cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) + !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || @@ -157,7 +157,7 @@ fun SendMsgView( fun MenuItems(): List<@Composable () -> Unit> { val menuItems = mutableListOf<@Composable () -> Unit>() - if (cs.liveMessage == null && !cs.editing && !nextSendGrpInv || sendMsgEnabled) { + if (cs.liveMessage == null && !cs.editing && !cs.forwarding && !nextSendGrpInv || sendMsgEnabled) { if ( cs.preview !is ComposePreview.VoicePreview && cs.contextItem is ComposeContextItem.NoContextItem && diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 68a8eb44a4..19cc949543 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -95,8 +95,8 @@ private fun featureInfo(ci: ChatItem, chatInfo: ChatInfo): FeatureInfo? = when (ci.content) { is CIContent.RcvChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) is CIContent.SndChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) - is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as ChatInfo.Group).groupInfo.membership).iconColor, ci.content.param, ci.content.groupFeature.name) - is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as ChatInfo.Group).groupInfo.membership).iconColor, ci.content.param, ci.content.groupFeature.name) + is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, ci.content.param, ci.content.groupFeature.name) + is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, ci.content.param, ci.content.groupFeature.name) else -> null } 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 f7909eed12..da766d920e 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 @@ -59,18 +59,11 @@ fun CIFileView( } } - fun fileSizeValid(): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false - } - fun fileAction() { if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation -> { - if (fileSizeValid()) { + if (fileSizeValid(file)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( @@ -165,7 +158,7 @@ fun CIFileView( is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.RcvInvitation -> - if (fileSizeValid()) + if (fileSizeValid(file)) fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary) else fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) @@ -216,6 +209,8 @@ fun CIFileView( } } +fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) + @Composable fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = rememberFileChooserLauncher(false, ciFile) { to: URI? -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index fe70139d91..427f34b2e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -19,6 +19,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* @@ -40,6 +41,7 @@ fun chatEventText(eventText: String, ts: String): AnnotatedString = @Composable fun ChatItemView( + rhId: Long?, cInfo: ChatInfo, cItem: ChatItem, composeState: MutableState, @@ -195,12 +197,16 @@ fun ChatItemView( } val clipboard = LocalClipboardManager.current val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } - val copyAndShareAllowed = when { - cItem.content.text.isNotEmpty() -> true + fun fileForwardingAllowed() = when { cItem.file != null && chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded -> true getLoadedFilePath(cItem.file) != null -> true else -> false } + val copyAndShareAllowed = when { + cItem.content.text.isNotEmpty() -> true + fileForwardingAllowed() -> true + else -> false + } if (copyAndShareAllowed) { ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { @@ -227,8 +233,19 @@ fun ChatItemView( showMenu.value = false }) } - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file?.fileSource] != false && cItem.file?.loaded == true))) { + if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { + ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { + withBGApi { + Log.d(TAG, "ChatItemView downloadFileAction") + val user = chatModel.currentUser.value + if (user != null) { + controller.receiveFile(rhId, user, cItem.file.fileId) + } + } + showMenu.value = false + }) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { @@ -236,6 +253,16 @@ fun ChatItemView( showMenu.value = false }) } + if (cItem.meta.itemDeleted == null && + (cItem.file == null || fileForwardingAllowed()) && + !cItem.isLiveDummy && !live + ) { + ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { + chatModel.chatId.value = null + chatModel.sharedContent.value = SharedContent.Forward(cItem, cInfo) + showMenu.value = false + }) + } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) if (revealed.value) { HideItemAction(revealed, showMenu) @@ -458,11 +485,11 @@ fun ChatItemView( MsgContentItemDropdownMenu() } is CIContent.RcvGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as ChatInfo.Group).groupInfo.membership).iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as ChatInfo.Group).groupInfo.membership).iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvChatFeatureRejected -> { @@ -782,6 +809,7 @@ expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) fun PreviewChatItemView() { SimpleXTheme { ChatItemView( + rhId = null, ChatInfo.Direct.sampleData, ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" @@ -818,6 +846,7 @@ fun PreviewChatItemView() { fun PreviewChatItemViewDeletedContent() { SimpleXTheme { ChatItemView( + rhId = null, ChatInfo.Direct.sampleData, ChatItem.getDeletedContentSampleData(), useLinkPreviews = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 3535c8fe26..a3b70e65ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -87,14 +87,14 @@ fun FramedItemView( } @Composable - fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null) { + fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) { val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Row( Modifier .background(if (sent) sentColor.toQuote() else receivedColor.toQuote()) .fillMaxWidth() - .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (ci.quotedItem == null) 6.dp else 0.dp), + .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -223,7 +223,7 @@ fun FramedItemView( if (ci.quotedItem != null) { ciQuoteView(ci.quotedItem) } else if (ci.meta.itemForwarded != null) { - FramedItemHeader(stringResource(MR.strings.forwarded_description), true, painterResource(MR.images.ic_forward)) + FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) } if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 5169d944c8..66061767e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -68,7 +68,7 @@ fun MarkdownText ( senderBold: Boolean = false, modifier: Modifier = Modifier, linkMode: SimplexLinkMode, - inlineContent: Map? = null, + inlineContent: Pair Unit, Map>? = null, onLinkLongClick: (link: String) -> Unit = {} ) { val textLayoutDirection = remember (text) { @@ -119,6 +119,7 @@ fun MarkdownText ( } if (formattedText == null) { val annotatedText = buildAnnotatedString { + inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (text is String) append(text) else if (text is AnnotatedString) append(text) @@ -127,10 +128,11 @@ fun MarkdownText ( } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent ?: mapOf()) + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } else { var hasAnnotations = false val annotatedText = buildAnnotatedString { + inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) for ((i, ft) in formattedText.withIndex()) { if (ft.format == null) append(ft.text) @@ -210,7 +212,7 @@ fun MarkdownText ( } ) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 1bb5a78996..336d104d2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -90,7 +90,7 @@ fun ChatPreviewView( Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) } - fun messageDraft(draft: ComposeState): Pair> { + fun messageDraft(draft: ComposeState): Pair Unit, Map> { fun attachment(): Pair? = when (draft.preview) { is ComposePreview.FilePreview -> MR.images.ic_draft_filled to draft.preview.fileName @@ -100,7 +100,7 @@ fun ChatPreviewView( } val attachment = attachment() - val text = buildAnnotatedString { + val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = { appendInlineContent(id = "editIcon") append(" ") if (attachment != null) { @@ -110,7 +110,6 @@ fun ChatPreviewView( } append(" ") } - append(draft.message) } val inlineContent: Map = mapOf( "editIcon" to InlineTextContent( @@ -124,7 +123,7 @@ fun ChatPreviewView( Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(MR.images.ic_edit_note), null, tint = MaterialTheme.colors.secondary) } ) - return text to inlineContent + return inlineContentBuilder to inlineContent } @Composable @@ -169,7 +168,7 @@ fun ChatPreviewView( if (ci != null) { if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) { val (text: CharSequence, inlineTextContent) = when { - chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) } + chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft) } ci.meta.itemDeleted == null -> ci.text to null else -> markedDeletedText(ci.meta) to null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 04fef25ac2..a36930f5ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -13,9 +13,8 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.SettingsViewState +import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.model.Chat -import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.res.MR import kotlinx.coroutines.flow.MutableStateFlow @@ -74,7 +73,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState val navButton: @Composable RowScope.() -> Unit = { when { showSearch -> NavigationButtonBack(hideSearchOnBack) - users.size > 1 || chatModel.remoteHosts.isNotEmpty() -> { + (users.size > 1 || chatModel.remoteHosts.isNotEmpty()) && remember { chatModel.sharedContent }.value !is SharedContent.Forward -> { val allRead = users .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } @@ -82,7 +81,14 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState userPickerState.value = AnimatedViewState.VISIBLE } } - else -> NavigationButtonBack(onButtonClicked = { chatModel.sharedContent.value = null }) + else -> NavigationButtonBack(onButtonClicked = { + val sharedContent = chatModel.sharedContent.value + // Drop shared content + chatModel.sharedContent.value = null + if (sharedContent is SharedContent.Forward) { + chatModel.chatId.value = sharedContent.fromChatInfo.id + } + }) } } if (chatModel.chats.size >= 8) { @@ -118,7 +124,8 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState is SharedContent.Text -> stringResource(MR.strings.share_message) is SharedContent.Media -> stringResource(MR.strings.share_image) is SharedContent.File -> stringResource(MR.strings.share_file) - else -> stringResource(MR.strings.share_message) + is SharedContent.Forward -> stringResource(MR.strings.forward_message) + null -> stringResource(MR.strings.share_message) }, color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.SemiBold, @@ -135,12 +142,14 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState @Composable private fun ShareList(chatModel: ChatModel, search: String) { - val filter: (Chat) -> Boolean = { chat: Chat -> - chat.chatInfo.chatViewName.lowercase().contains(search.lowercase()) - } val chats by remember(search) { derivedStateOf { - if (search.isEmpty()) chatModel.chats.toList().filter { it.chatInfo.ready } else chatModel.chats.toList().filter { it.chatInfo.ready }.filter(filter) + val sorted = chatModel.chats.toList().sortedByDescending { it.chatInfo is ChatInfo.Local } + if (search.isEmpty()) { + sorted.filter { it.chatInfo.ready } + } else { + sorted.filter { it.chatInfo.ready && it.chatInfo.chatViewName.lowercase().contains(search.lowercase()) } + } } } LazyColumnWithScrollBar( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt index 67f82e5279..ee4638445b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt @@ -2,6 +2,8 @@ package chat.simplex.common.views.helpers import androidx.compose.runtime.saveable.Saver +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.ChatItem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.* import kotlinx.serialization.descriptors.* @@ -13,6 +15,7 @@ sealed class SharedContent { data class Text(val text: String): SharedContent() data class Media(val text: String, val uris: List): SharedContent() data class File(val text: String, val uri: URI): SharedContent() + data class Forward(val chatItem: ChatItem, val fromChatInfo: ChatInfo): SharedContent() } enum class AnimatedViewState { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b0287ed9fc..7c7cb2c384 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -47,6 +47,8 @@ LIVE moderated forwarded + saved + saved from %s invalid chat invalid data error showing message @@ -268,6 +270,11 @@ History No history In reply to + Saved + Forwarded + Saved from + Forwarded from + Recipient(s) can\'t see who this message is from. Delivery No delivery information Delete @@ -295,6 +302,8 @@ Revoke file? File will be deleted from servers. Revoke + Forward + Download edited @@ -329,6 +338,7 @@ Share message… Share media… Share file… + Forward message… Attach