From d4d5643d3ca95d3dac15735387e7f8d37591f848 Mon Sep 17 00:00:00 2001 From: Avently <7953703+avently@users.noreply.github.com> Date: Wed, 17 Apr 2024 06:15:03 +0700 Subject: [PATCH] android, desktop: forward ui --- .../java/chat/simplex/app/MainActivity.kt | 15 +-- .../chat/simplex/common/model/ChatModel.kt | 26 ++++- .../chat/simplex/common/model/SimpleXAPI.kt | 17 ++- .../common/views/chat/ChatItemInfoView.kt | 102 ++++++++++++++++++ .../simplex/common/views/chat/ComposeView.kt | 41 ++++++- .../common/views/chat/ContextItemView.kt | 29 ++++- .../simplex/common/views/chat/SendMsgView.kt | 4 +- .../views/chat/item/CIChatFeatureView.kt | 4 +- .../common/views/chat/item/ChatItemView.kt | 11 +- .../common/views/chat/item/FramedItemView.kt | 6 +- .../common/views/chatlist/ShareListView.kt | 5 +- .../simplex/common/views/helpers/Enums.kt | 3 + .../commonMain/resources/MR/base/strings.xml | 9 ++ 13 files changed, 243 insertions(+), 29 deletions(-) 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..38340a3437 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() } @@ -106,7 +107,9 @@ class MainActivity: FragmentActivity() { if (!onBackPressedDispatcher.hasEnabledCallbacks()) { // Drop shared content SimplexApp.context.chatModel.sharedContent.value = null - finish() + 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..49927c7d16 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 @@ -20,6 +20,7 @@ import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -29,6 +30,8 @@ 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.openDirectChat +import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -36,6 +39,7 @@ 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 @@ -151,6 +155,76 @@ 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 ChatPreviewTitleText(text: String, color: Color, fontStyle: FontStyle = FontStyle.Normal) { + Text( + text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h3, + fontStyle = fontStyle, + color = color + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + ChatInfoImage(forwardedFromItem.chatInfo, size = 72.dp) + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1F) + ) { + Column { + if (forwardedFromItem.chatItem.chatDir.sent) { + ChatPreviewTitleText(text = stringResource(MR.strings.sender_you_pronoun), color = MaterialTheme.colors.primary, fontStyle = FontStyle.Italic) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + Text(forwardedFromItem.chatInfo.chatViewName, maxLines = 1, style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp)) + } else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) { + ChatPreviewTitleText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName, color = MaterialTheme.colors.primary) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + Text(forwardedFromItem.chatInfo.chatViewName, maxLines = 1, style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp)) + } else { + Text(forwardedFromItem.chatInfo.chatViewName, maxLines = 1, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp)) + } + } + } + } + } + + @Composable + fun ForwardedFromView(forwardedFromItem: AChatItem) { + Column { + SectionItemView( + click = { + withBGApi { + if (forwardedFromItem.chatInfo is ChatInfo.Direct) { + openDirectChat(chatModel.remoteHostId(), forwardedFromItem.chatInfo.apiId, chatModel) + } else { + openGroupChat(chatModel.remoteHostId(), forwardedFromItem.chatInfo.apiId, chatModel) + } + } + ModalManager.end.closeModals() + }, + padding = PaddingValues(start = 15.dp, end = DEFAULT_PADDING) + ) { + ForwardedFromSender(forwardedFromItem) + } + + if (!local) { + Divider(Modifier.padding(start = DEFAULT_PADDING, top = 40.dp, end = DEFAULT_PADDING, 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)) @@ -222,6 +296,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 = false) + 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( @@ -297,6 +387,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 +396,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 +408,9 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d if (ci.quotedItem != null) { numTabs += 1 } + if (ciInfo.forwardedFromChatItem != null) { + numTabs += 1 + } return numTabs } @@ -344,6 +439,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 +453,9 @@ 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)) + } 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/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 1c8b32d467..57f74b9600 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,22 @@ 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(chat.remoteHostId, chat.chatInfo, chatItem) + return chatItem + } + return null + } + fun checkLinkPreview(): MsgContent { return when (val composePreview = cs.preview) { is ComposePreview.CLinkPreview -> { @@ -465,6 +488,11 @@ fun ComposeView( 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 +591,12 @@ fun ComposeView( sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } + val wasForwarding = cs.forwarding clearState(live) + val draft = chatModel.draft.value + if (wasForwarding && chatModel.draftChatId.value == chat.chatInfo.id && draft != null) { + composeState.value = draft + } return sent } @@ -745,6 +778,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 +800,7 @@ 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)) null -> {} } chatModel.sharedContent.value = null 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..63119207bf 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 @@ -24,6 +24,7 @@ import kotlinx.datetime.Clock fun ContextItemView( contextItem: ChatItem, contextIcon: Painter, + showSender: Boolean = true, cancelContextItem: () -> Unit ) { val sent = contextItem.chatDir.sent @@ -31,9 +32,10 @@ fun ContextItemView( val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage @Composable - fun msgContentView(lines: Int) { + fun MessageText(lines: Int) { MarkdownText( contextItem.text, contextItem.formattedText, + sender = null, toggleSecrets = false, maxLines = lines, linkMode = SimplexLinkMode.DESCRIPTION, @@ -41,6 +43,25 @@ fun ContextItemView( ) } + @Composable + fun Attachment() { + when (contextItem.content.msgContent) { + is MsgContent.MCFile -> Icon(painterResource(MR.images.ic_draft_filled), null, tint = MaterialTheme.colors.secondary) + is MsgContent.MCImage -> Icon(painterResource(MR.images.ic_image), null, tint = MaterialTheme.colors.secondary) + is MsgContent.MCVoice -> Icon(painterResource(MR.images.ic_play_arrow_filled), null, tint = MaterialTheme.colors.secondary) + else -> {} + } + } + + @Composable + fun ContextMsgPreview(lines: Int) { + Row { + Attachment() + Spacer(Modifier.width(4.dp)) + MessageText(lines) + } + } + Row( Modifier .padding(top = 8.dp) @@ -64,7 +85,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 +94,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/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index fe70139d91..b47a41a461 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 @@ -236,6 +236,13 @@ fun ChatItemView( showMenu.value = false }) } + if (cItem.meta.itemDeleted == null && !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 +465,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 -> { 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..82c8800601 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 = true) { 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/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 04fef25ac2..0087969c94 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 @@ -74,7 +74,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 } @@ -118,7 +118,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, 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..18d1b5f43f 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,7 @@ Revoke file? File will be deleted from servers. Revoke + Forward edited @@ -329,6 +337,7 @@ Share message… Share media… Share file… + Forward message… Attach