From a2fa2be87e1fde3717fbdbe64721ffef1faf7917 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 19 Apr 2026 11:26:54 +0100 Subject: [PATCH] android, desktop: sharing channel links (#6828) * android, desktop: sharing channel links - types, api, strings * implementation * fix build * improve layout * improve card layouts * better divider * preview image * icon, preview * better icons * bigger icon * darker icons * better icon colors * better layouts * compose preview for chat links * sizes * fix editing --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Chat/ChatItem/CIChatLinkHeader.swift | 1 + .../ComposeMessage/ComposeChatLinkView.swift | 3 +- .../Chat/ComposeMessage/ComposeView.swift | 10 +- .../chat/simplex/common/model/ChatModel.kt | 91 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 38 +- .../chat/simplex/common/ui/theme/Color.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 2 +- .../common/views/chat/ComposeChatLinkView.kt | 53 ++ .../simplex/common/views/chat/ComposeView.kt | 45 +- .../common/views/chat/ContextItemView.kt | 19 +- .../views/chat/group/GroupChatInfoView.kt | 16 +- .../common/views/chat/group/GroupLinkView.kt | 88 +-- .../views/chat/group/GroupProfileView.kt | 2 +- .../views/chat/item/CIChatLinkHeader.kt | 79 +++ .../common/views/chat/item/CIFileView.kt | 3 +- .../views/chat/item/CIGroupInvitationView.kt | 25 +- .../common/views/chat/item/FramedItemView.kt | 45 +- .../common/views/chat/item/TextItemView.kt | 20 + .../common/views/chatlist/ChatPreviewView.kt | 34 +- .../common/views/chatlist/ShareListView.kt | 8 +- .../common/views/helpers/AlertManager.kt | 11 + .../simplex/common/views/helpers/Enums.kt | 1 + .../common/views/newchat/AddChannelView.kt | 2 +- .../common/views/newchat/ConnectPlan.kt | 30 +- .../commonMain/resources/MR/base/strings.xml | 13 + .../ic_bigtop_updates_circle_filled.svg | 1 + .../MR/images/ic_bigtop_updates_padded.svg | 8 - plans/2026-04-17-kotlin-share-channel-link.md | 561 ++++++++++++++++++ 28 files changed, 1105 insertions(+), 108 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg delete mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg create mode 100644 plans/2026-04-17-kotlin-share-channel-link.md diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift index d26506e871..aaaa29929d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift @@ -18,6 +18,7 @@ struct CIChatLinkHeader: View { .padding(.top, 8) .padding(.bottom, 6) .overlay(DetermineWidth()) + Divider() VStack(alignment: .leading, spacing: 2) { if let descr = chatLink.shortDescription { Text(descr) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift index d82029df0e..650ea8a87f 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift @@ -34,10 +34,9 @@ struct ComposeChatLinkView: View { } } } - .padding(.vertical, 1) + .padding(.vertical, 8) .padding(.trailing, 12) .background(theme.appColors.sentMessage) - .frame(minHeight: 54) .frame(maxWidth: .infinity) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 656a222cd0..29daaf37fa 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -74,7 +74,11 @@ struct ComposeState { } init(editingItem: ChatItem) { - let text = editingItem.content.text + let text = if case let .chat(t, chatLink, _) = editingItem.content.msgContent { + stripTextLink(t, chatLink.connLinkStr) + } else { + editingItem.content.text + } self.message = text self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text) self.preview = chatItemPreview(chatItem: editingItem) @@ -1577,9 +1581,9 @@ struct ComposeView: View { return .file(msgText) case .report(_, let reason): return .report(text: msgText, reason: reason) - // TODO [short links] update chat link case let .chat(_, chatLink, ownerSig): - return .chat(text: msgText, chatLink: chatLink, ownerSig: ownerSig) + let text = msgText.isEmpty ? chatLink.connLinkStr : msgText + "\n" + chatLink.connLinkStr + return .chat(text: text, chatLink: chatLink, ownerSig: ownerSig) case .unknown(let type, _): return .unknown(type: type, text: msgText) } 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 7078c4c404..591dde89cd 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 @@ -2113,7 +2113,7 @@ data class GroupInfo ( val chatIconName: ImageResource get() = if (useRelays) { - MR.images.ic_bigtop_updates_padded + MR.images.ic_bigtop_updates_circle_filled } else when (businessChat?.chatType) { null -> MR.images.ic_supervised_user_circle_filled BusinessChatType.Business -> MR.images.ic_work_filled_padded @@ -4307,7 +4307,7 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCReport(override val text: String, val reason: ReportReason): MsgContent() - @Serializable(with = MsgContentSerializer::class) class MCChat(override val text: String, val chatLink: MsgChatLink): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCChat(override val text: String, val chatLink: MsgChatLink, val ownerSig: LinkOwnerSig? = null): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val isVoice: Boolean get() = @@ -4428,7 +4428,8 @@ object MsgContentSerializer : KSerializer { } "chat" -> { val chatLink = decoder.json.decodeFromString(json["chatLink"].toString()) - MsgContent.MCChat(text, chatLink) + val ownerSig = json["ownerSig"]?.let { decoder.json.decodeFromJsonElement(it) } + MsgContent.MCChat(text, chatLink, ownerSig) } else -> MsgContent.MCUnknown(t, text, json) } @@ -4489,6 +4490,7 @@ object MsgContentSerializer : KSerializer { put("type", "chat") put("text", value.text) put("chatLink", json.encodeToJsonElement(value.chatLink)) + value.ownerSig?.let { put("ownerSig", json.encodeToJsonElement(it)) } } is MsgContent.MCUnknown -> value.json } @@ -4548,8 +4550,91 @@ sealed class MsgChatLink { @Serializable @SerialName("contact") data class Contact(val connLink: String, val profile: Profile, val business: Boolean) : MsgChatLink() @Serializable @SerialName("invitation") data class Invitation(val invLink: String, val profile: Profile) : MsgChatLink() @Serializable @SerialName("group") data class Group(val connLink: String, val groupProfile: GroupProfile) : MsgChatLink() + + val isPublicGroup: Boolean + get() = (this as? Group)?.groupProfile?.publicGroup != null + + val connLinkStr: String + get() = when (this) { + is Group -> connLink + is Contact -> connLink + is Invitation -> invLink + } + + val image: String? + get() = when (this) { + is Group -> groupProfile.image + is Contact -> profile.image + is Invitation -> profile.image + } + + val displayName: String + get() = when (this) { + is Group -> groupProfile.displayName + is Contact -> profile.displayName + is Invitation -> profile.displayName + } + + val fullName: String + get() = when (this) { + is Group -> groupProfile.fullName + is Contact -> profile.fullName + is Invitation -> profile.fullName + } + + val shortDescription: String? + get() { + val s = when (this) { + is Group -> groupProfile.shortDescr + is Contact -> profile.shortDescr + is Invitation -> profile.shortDescr + } + return s?.trim()?.ifEmpty { null } + } + + val iconRes: ImageResource + get() = when (this) { + is Group -> when (groupProfile.publicGroup?.groupType) { + GroupType.Channel -> MR.images.ic_bigtop_updates_circle_filled + else -> MR.images.ic_supervised_user_circle_filled + } + is Contact -> if (business) MR.images.ic_work_filled_padded else MR.images.ic_account_circle_filled + is Invitation -> MR.images.ic_account_circle_filled + } + + val smallIconRes: ImageResource + get() = when (this) { + is Group -> when (groupProfile.publicGroup?.groupType) { + GroupType.Channel -> MR.images.ic_bigtop_updates + else -> MR.images.ic_group + } + is Contact -> if (business) MR.images.ic_work else MR.images.ic_person + is Invitation -> MR.images.ic_person + } + + fun infoLine(signed: Boolean): String { + var s = when (this) { + is Group -> when (groupProfile.publicGroup?.groupType) { + GroupType.Channel -> generalGetString(MR.strings.chat_link_channel) + else -> generalGetString(MR.strings.chat_link_group) + } + is Contact -> if (business) generalGetString(MR.strings.chat_link_business_address) else generalGetString(MR.strings.chat_link_contact_address) + is Invitation -> generalGetString(MR.strings.chat_link_one_time) + } + if (signed) { + s += " " + if (isPublicGroup) generalGetString(MR.strings.chat_link_from_owner) else generalGetString(MR.strings.chat_link_signed) + } + return s + } } +@Serializable +data class LinkOwnerSig( + val ownerId: String? = null, + val chatBinding: String, + val ownerSig: String +) + @Serializable class FormattedText(val text: String, val format: Format? = null) { val linkUri: String? get() = 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 fc4471f395..d4f1fa203d 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 @@ -1135,6 +1135,13 @@ object ChatController { return processSendMessageCmd(rh, cmd)?.map { it.chatItem } } + suspend fun apiShareChatMsgContent(rh: Long?, shareChatType: ChatType, shareChatId: Long, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, sendAsGroup: Boolean): MsgContent? { + val r = sendCmd(rh, CC.ApiShareChatMsgContent(shareChatType, shareChatId, toChatType, toChatId, toScope, sendAsGroup)) + if (r is API.Result && r.res is CR.ChatMsgContent) return r.res.msgContent + apiErrorAlert("apiShareChatMsgContent", generalGetString(MR.strings.error_sharing_channel), r) + return null + } + suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, chatItemIds: List): CR.ForwardPlan? { val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, fromScope, chatItemIds)) if (r is API.Result && r.res is CR.ForwardPlan) return r.res @@ -1485,9 +1492,9 @@ object ChatController { return null } - suspend fun apiConnectPlan(rh: Long?, connLink: String, inProgress: MutableState): Pair? { + suspend fun apiConnectPlan(rh: Long?, connLink: String, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState): Pair? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } - val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink), inProgress = inProgress) + val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, linkOwnerSig), inProgress = inProgress) if (r is API.Result && r.res is CR.CRConnectionPlan) return r.res.connLink to r.res.connectionPlan if (inProgress.value && r != null) apiConnectResponseAlert(r) return null @@ -3624,6 +3631,7 @@ sealed class CC { class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC() class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val chatItemIds: List): CC() class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val sendAsGroup: Boolean, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() + class ApiShareChatMsgContent(val shareChatType: ChatType, val shareChatId: Long, val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val sendAsGroup: Boolean): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiNewPublicGroup(val userId: Long, val incognito: Boolean, val relayIds: List, val groupProfile: GroupProfile): CC() class ApiGetGroupRelays(val groupId: Long): CC() @@ -3683,7 +3691,7 @@ sealed class CC { class APIAddContact(val userId: Long, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC() - class APIConnectPlan(val userId: Long, val connLink: String): CC() + class APIConnectPlan(val userId: Long, val connLink: String, val linkOwnerSig: LinkOwnerSig? = null): CC() class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData): CC() class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData): CC() class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC() @@ -3822,6 +3830,9 @@ sealed class CC { val ttlStr = if (ttl != null) "$ttl" else "default" "/_forward ${chatRef(toChatType, toChatId, toScope)}${if (sendAsGroup) " as_group=on" else ""} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" } + is ApiShareChatMsgContent -> { + "/_share chat content ${chatRef(shareChatType, shareChatId, null)} ${chatRef(toChatType, toChatId, toScope)}${if (sendAsGroup) "(as_group=on)" else ""}" + } is ApiPlanForwardChatItems -> { "/_forward plan ${chatRef(fromChatType, fromChatId, fromScope)} ${chatItemIds.joinToString(",")}" } @@ -3884,7 +3895,10 @@ sealed class CC { is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" is ApiChangeConnectionUser -> "/_set conn user :$connId $userId" - is APIConnectPlan -> "/_connect plan $userId $connLink" + is APIConnectPlan -> { + val sigStr = if (linkOwnerSig != null) " sig=${json.encodeToString(linkOwnerSig)}" else "" + "/_connect plan $userId $connLink$sigStr" + } is APIPrepareContact -> "/_prepare contact $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(contactShortLinkData)}" is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} direct=${onOff(directLink)} ${json.encodeToString(groupShortLinkData)}" is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId" @@ -4003,6 +4017,7 @@ sealed class CC { is ApiChatItemReaction -> "apiChatItemReaction" is ApiGetReactionMembers -> "apiGetReactionMembers" is ApiForwardChatItems -> "apiForwardChatItems" + is ApiShareChatMsgContent -> "apiShareChatMsgContent" is ApiPlanForwardChatItems -> "apiPlanForwardChatItems" is ApiNewGroup -> "apiNewGroup" is ApiNewPublicGroup -> "apiNewPublicGroup" @@ -6318,6 +6333,7 @@ sealed class CR { @Serializable @SerialName("subscriptionStatus") class SubscriptionStatusEvt(val subscriptionStatus: SubscriptionStatus, val connections: List): CR() @Serializable @SerialName("chatInfoUpdated") class ChatInfoUpdated(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("newChatItems") class NewChatItems(val user: UserRef, val chatItems: List): CR() + @Serializable @SerialName("chatMsgContent") class ChatMsgContent(val user: UserRef, val msgContent: MsgContent): CR() @Serializable @SerialName("chatItemsStatusesUpdated") class ChatItemsStatusesUpdated(val user: UserRef, val chatItems: List): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR() @@ -6506,6 +6522,7 @@ sealed class CR { is SubscriptionStatusEvt -> "subscriptionStatus" is ChatInfoUpdated -> "chatInfoUpdated" is NewChatItems -> "newChatItems" + is ChatMsgContent -> "chatMsgContent" is ChatItemsStatusesUpdated -> "chatItemsStatusesUpdated" is ChatItemUpdated -> "chatItemUpdated" is ChatItemNotChanged -> "chatItemNotChanged" @@ -6686,6 +6703,7 @@ sealed class CR { is SubscriptionStatusEvt -> "subscriptionStatus $subscriptionStatus\nconnections: $connections" is ChatInfoUpdated -> withUser(user, json.encodeToString(chatInfo)) is NewChatItems -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) + is ChatMsgContent -> withUser(user, msgContent.toString()) is ChatItemsStatusesUpdated -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem)) is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem)) @@ -6840,6 +6858,12 @@ fun simplexChatLink(uri: String): String = if (uri.startsWith("simplex:/")) uri.replace("simplex:/", "https://simplex.chat/") else uri +@Serializable +sealed class OwnerVerification { + @Serializable @SerialName("verified") object Verified : OwnerVerification() + @Serializable @SerialName("failed") class Failed(val reason: String) : OwnerVerification() +} + @Serializable sealed class ConnectionPlan { @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() @@ -6850,7 +6874,7 @@ sealed class ConnectionPlan { @Serializable sealed class InvitationLinkPlan { - @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null): InvitationLinkPlan() + @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): InvitationLinkPlan() @Serializable @SerialName("ownLink") object OwnLink: InvitationLinkPlan() @Serializable @SerialName("connecting") class Connecting(val contact_: Contact? = null): InvitationLinkPlan() @Serializable @SerialName("known") class Known(val contact: Contact): InvitationLinkPlan() @@ -6858,7 +6882,7 @@ sealed class InvitationLinkPlan { @Serializable sealed class ContactAddressPlan { - @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null): ContactAddressPlan() + @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): ContactAddressPlan() @Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() @@ -6868,7 +6892,7 @@ sealed class ContactAddressPlan { @Serializable sealed class GroupLinkPlan { - @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() + @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null): GroupLinkPlan() @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt index c50ea5c349..e3c86352d2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt @@ -29,8 +29,8 @@ val GroupDark = Color(80, 80, 80, 60) val IncomingCallLight = Color(239, 237, 236, 255) val WarningOrange = Color(255, 127, 0, 255) val WarningYellow = Color(255, 192, 0, 255) -val FileLight = Color(183, 190, 199, 255) -val FileDark = Color(101, 101, 106, 255) +val FileLight = Color(191, 194, 199, 255) +val FileDark = Color(94, 94, 98, 255) val MenuTextColor: Color @Composable get () = if (isInDarkTheme()) LocalContentColor.current.copy(alpha = 0.8f) else Color.Black val NoteFolderIconColor: Color @Composable get() = MaterialTheme.appColors.primaryVariant2 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 dc5747e69e..40a89dd8a6 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 @@ -3204,7 +3204,7 @@ fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: ( val link = chatModel.controller.apiGetGroupLink(rhId, groupInfo.groupId) close?.invoke() ModalManager.end.showModalCloseable(true) { - GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null, isChannel = groupInfo.useRelays) + GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt new file mode 100644 index 0000000000..14edea3ed6 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt @@ -0,0 +1,53 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.MsgChatLink +import chat.simplex.common.ui.theme.appColors +import chat.simplex.common.views.helpers.ProfileImage +import dev.icerock.moko.resources.compose.painterResource +import chat.simplex.res.MR + +@Composable +fun ComposeChatLinkView( + chatLink: MsgChatLink, + cancelEnabled: Boolean, + cancelPreview: () -> Unit +) { + val sentColor = MaterialTheme.appColors.sentMessage + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .background(sentColor) + .padding(start = 8.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileImage(size = 54.dp, image = chatLink.image, icon = chatLink.iconRes) + Column( + Modifier.fillMaxWidth().weight(1f).padding(horizontal = 8.dp) + ) { + Text(chatLink.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) + chatLink.shortDescription?.let { descr -> + Text( + descr, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + ) + } + } + if (cancelEnabled) { + IconButton(onClick = cancelPreview) { + Icon(painterResource(MR.images.ic_close), null, tint = MaterialTheme.colors.primary) + } + } + } +} 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 69990764de..83e76a87ff 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 @@ -57,6 +57,7 @@ const val MAX_NUMBER_OF_MENTIONS = 3 sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() + @Serializable class ChatLinkPreview(val chatLink: MsgChatLink, val ownerSig: LinkOwnerSig? = null): ComposePreview() @Serializable class MediaPreview(val images: List, val content: List): ComposePreview() @Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview() @Serializable class FilePreview(val fileName: String, val uri: URI): ComposePreview() @@ -112,7 +113,12 @@ data class ComposeState( val mentions: MentionedMembers = emptyMap() ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( - ComposeMessage(editingItem.content.text), + ComposeMessage( + when (val mc = editingItem.content.msgContent) { + is MsgContent.MCChat -> stripTextLink(mc.text, mc.chatLink.connLinkStr) + else -> editingItem.content.text + } + ), editingItem.formattedText ?: FormattedText.plain(editingItem.content.text), liveMessage, chatItemPreview(editingItem), @@ -163,6 +169,7 @@ data class ComposeState( val hasContent = when (preview) { is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true + is ComposePreview.ChatLinkPreview -> true is ComposePreview.FilePreview -> true else -> !whitespaceOnly || forwarding || liveMessage != null || submittingValidReport } @@ -174,6 +181,7 @@ data class ComposeState( val linkPreviewAllowed: Boolean get() = when (preview) { + is ComposePreview.ChatLinkPreview -> false is ComposePreview.MediaPreview -> false is ComposePreview.VoicePreview -> false is ComposePreview.FilePreview -> false @@ -200,6 +208,7 @@ data class ComposeState( get() = when (preview) { ComposePreview.NoPreview -> false is ComposePreview.CLinkPreview -> false + is ComposePreview.ChatLinkPreview -> false is ComposePreview.MediaPreview -> preview.content.isNotEmpty() is ComposePreview.VoicePreview -> false is ComposePreview.FilePreview -> true @@ -468,6 +477,7 @@ fun ComposeView( is SharedContent.File -> listOf(shared.uri.toString()) is SharedContent.Text -> emptyList() is SharedContent.Forward -> emptyList() + is SharedContent.ChatLink -> 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 -> @@ -672,8 +682,11 @@ fun ComposeView( is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) is MsgContent.MCFile -> MsgContent.MCFile(msgText) is MsgContent.MCReport -> MsgContent.MCReport(msgText, reason = msgContent.reason) - // TODO [short links] update chat link - is MsgContent.MCChat -> MsgContent.MCChat(msgText, chatLink = msgContent.chatLink) + is MsgContent.MCChat -> { + val linkStr = msgContent.chatLink.connLinkStr + val text = if (msgText.isEmpty()) linkStr else "$msgText\n$linkStr" + MsgContent.MCChat(text, chatLink = msgContent.chatLink, ownerSig = msgContent.ownerSig) + } is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) } } @@ -760,6 +773,11 @@ fun ComposeView( when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) + is ComposePreview.ChatLinkPreview -> { + val linkStr = preview.chatLink.connLinkStr + val text = if (msgText.isEmpty()) linkStr else "$msgText\n$linkStr" + msgs.add(MsgContent.MCChat(text, preview.chatLink, preview.ownerSig)) + } is ComposePreview.MediaPreview -> { // TODO batch send: batch media previews preview.content.forEachIndexed { index, it -> @@ -1060,6 +1078,11 @@ fun ComposeView( ::cancelLinkPreview, cancelEnabled = !composeState.value.inProgress ) + is ComposePreview.ChatLinkPreview -> ComposeChatLinkView( + chatLink = preview.chatLink, + cancelEnabled = !composeState.value.inProgress, + cancelPreview = { composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) } + ) is ComposePreview.MediaPreview -> ComposeImageView( preview, ::cancelImages, @@ -1440,6 +1463,22 @@ fun ComposeView( contextItem = ComposeContextItem.ForwardingItems(shared.chatItems, shared.fromChatInfo), preview = if (composeState.value.preview is ComposePreview.CLinkPreview) composeState.value.preview else ComposePreview.NoPreview ) + is SharedContent.ChatLink -> { + val cInfo = chat.chatInfo + val sendAsGroup = (cInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false + withBGApi { + val mc = chatModel.controller.apiShareChatMsgContent( + chat.remoteHostId, ChatType.Group, shared.groupInfo.groupId, + cInfo.chatType, cInfo.apiId, + cInfo.groupChatScope(), sendAsGroup + ) + if (mc is MsgContent.MCChat) { + composeState.value = composeState.value.copy( + preview = ComposePreview.ChatLinkPreview(mc.chatLink, mc.ownerSig) + ) + } + } + } 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 1501fb7938..e681c4fed7 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 @@ -39,7 +39,7 @@ fun ContextItemView( val receivedColor = MaterialTheme.appColors.receivedMessage @Composable - fun MessageText(contextItem: ChatItem, attachment: ImageResource?, lines: Int) { + fun MessageText(contextItem: ChatItem, attachment: ImageResource?, lines: Int, prefix: AnnotatedString? = null, stripLink: String? = null) { val inlineContent: Pair Unit, Map>? = if (attachment != null) { remember(contextItem.id) { val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = { @@ -68,24 +68,35 @@ fun ContextItemView( userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null - } + }, + prefix = prefix, + stripLink = stripLink, ) } fun attachment(contextItem: ChatItem): ImageResource? { val fileIsLoaded = getLoadedFilePath(contextItem.file) != null - return when (contextItem.content.msgContent) { + val mc = contextItem.content.msgContent + return when (mc) { is MsgContent.MCFile -> if (fileIsLoaded) MR.images.ic_draft_filled else null is MsgContent.MCImage -> MR.images.ic_image is MsgContent.MCVoice -> if (fileIsLoaded) MR.images.ic_play_arrow_filled else null + is MsgContent.MCChat -> mc.chatLink.smallIconRes else -> null } } @Composable fun ContextMsgPreview(contextItem: ChatItem, lines: Int) { - MessageText(contextItem, remember(contextItem.id) { attachment(contextItem) }, lines) + val mc = contextItem.content.msgContent + if (mc is MsgContent.MCChat) { + val hasText = contextItem.text != mc.chatLink.connLinkStr + val prefix = buildAnnotatedString { append(mc.chatLink.displayName + if (hasText) " - " else "") } + MessageText(contextItem, remember(contextItem.id) { mc.chatLink.smallIconRes }, lines, prefix = prefix, stripLink = mc.chatLink.connLinkStr) + } else { + MessageText(contextItem, remember(contextItem.id) { attachment(contextItem) }, lines) + } } val sent = contextItems[0].chatDir.sent diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 78eb31ccbe..767eb46923 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -167,7 +167,7 @@ fun ModalData.GroupChatInfoView( clearChat = { clearChatDialog(chat, close) }, leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays) } + ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } }, onSearchClicked = onSearchClicked, deletingItems = deletingItems @@ -554,6 +554,11 @@ fun ModalData.GroupChatInfoLayout( } else if (channelLink != null) { anyTopSectionRowShow = true ChannelLinkQRCodeSection(channelLink) + ShareViaChatButton { + chatModel.sharedContent.value = SharedContent.ChatLink(groupInfo) + chatModel.chatId.value = null + ModalManager.closeAllModalsEverywhere() + } } if (groupInfo.isOwner || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) { anyTopSectionRowShow = true @@ -1138,6 +1143,15 @@ private fun ChannelLinkQRCodeSection(groupLink: String) { } } +@Composable +private fun ShareViaChatButton(onClick: () -> Unit) { + SectionItemView(onClick) { + Icon(painterResource(MR.images.ic_forward), null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Text(stringResource(MR.strings.share_via_chat), color = MaterialTheme.colors.primary) + } +} + @Composable private fun ChannelMembersButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { val title = if (groupInfo.isOwner) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index c9745359b9..c0b107b5dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -33,6 +33,7 @@ fun GroupLinkView( onGroupLinkUpdated: ((GroupLink?) -> Unit)?, creatingGroup: Boolean = false, isChannel: Boolean = false, + shareGroupInfo: GroupInfo? = null, close: (() -> Unit)? = null ) { var groupLinkVar by rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf(groupLink) } @@ -124,6 +125,7 @@ fun GroupLinkView( groupLinkMemberRole, creatingLink, isChannel = isChannel, + shareGroupInfo = shareGroupInfo, createLink = ::createLink, showAddShortLinkAlert = ::showAddShortLinkAlert, updateLink = { @@ -171,6 +173,7 @@ fun GroupLinkLayout( groupLinkMemberRole: MutableState, creatingLink: Boolean, isChannel: Boolean = false, + shareGroupInfo: GroupInfo? = null, createLink: () -> Unit, showAddShortLinkAlert: ((() -> Unit)?) -> Unit, updateLink: () -> Unit, @@ -230,40 +233,56 @@ fun GroupLinkLayout( } else null) { SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) } - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) - ) { - val clipboard = LocalClipboardManager.current - SimpleButton( - stringResource(MR.strings.share_link), - icon = painterResource(MR.images.ic_share), - click = { - if (!isChannel && groupLink.shouldBeUpgraded) { - showAddShortLinkAlert { - clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) - } - } else { + if (!isChannel && groupLink.shouldBeUpgraded) { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.upgrade_group_link), + click = { showAddShortLinkAlert(null) }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + } + val clipboard = LocalClipboardManager.current + SettingsActionItem( + painterResource(MR.images.ic_share), + stringResource(MR.strings.share_link), + click = { + if (!isChannel && groupLink.shouldBeUpgraded) { + showAddShortLinkAlert { clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) } + } else { + clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) } + }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + if (shareGroupInfo != null) { + SettingsActionItem( + painterResource(MR.images.ic_forward), + stringResource(MR.strings.share_via_chat), + click = { + chatModel.sharedContent.value = SharedContent.ChatLink(shareGroupInfo) + chatModel.chatId.value = null + ModalManager.closeAllModalsEverywhere() + }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, ) - if (creatingGroup && close != null) { - ContinueButton(close) - } else if (!isChannel) { - SimpleButton( - stringResource(MR.strings.delete_link), - icon = painterResource(MR.images.ic_delete), - color = Color.Red, - click = deleteLink - ) - } } - if (!isChannel && groupLink.shouldBeUpgraded) { - AddShortLinkButton(text = stringResource(MR.strings.upgrade_group_link)) { - showAddShortLinkAlert(null) - } + if (!creatingGroup && !isChannel) { + SettingsActionItem( + painterResource(MR.images.ic_delete), + stringResource(MR.strings.delete_link), + click = deleteLink, + iconColor = Color.Red, + textColor = Color.Red, + ) + } + if (creatingGroup && close != null) { + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + ContinueButton(close) } } } @@ -271,17 +290,6 @@ fun GroupLinkLayout( } } -@Composable -private fun AddShortLinkButton(text: String, onClick: () -> Unit) { - SettingsActionItem( - painterResource(MR.images.ic_add), - text, - onClick, - iconColor = MaterialTheme.colors.primary, - textColor = MaterialTheme.colors.primary, - ) -} - @Composable private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState, enabled: Boolean = true) { Row( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index d144065399..6e91ad92d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -119,7 +119,7 @@ fun GroupProfileLayout( ) { Box(contentAlignment = Alignment.TopEnd) { Box(contentAlignment = Alignment.Center) { - ProfileImage(108.dp, profileImage.value, color = MaterialTheme.colors.secondary.copy(alpha = 0.1f)) + ProfileImage(108.dp, profileImage.value, icon = groupInfo.chatIconName, color = MaterialTheme.colors.secondary.copy(alpha = 0.1f)) EditImageButton { scope.launch { bottomSheetModalState.show() } } } if (profileImage.value != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt new file mode 100644 index 0000000000..3c3e4baf49 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt @@ -0,0 +1,79 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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 +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.ProfileImage +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun CIChatLinkHeader( + chatLink: MsgChatLink, + ownerSig: LinkOwnerSig?, + hasText: Boolean, +) { + Column( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 4.dp) + ) { + Row( + Modifier.defaultMinSize(minWidth = 220.dp) + ) { + ProfileImage( + size = 54.dp, + image = chatLink.image, + icon = chatLink.iconRes, + color = if (isInDarkTheme()) FileDark else FileLight + ) + Spacer(Modifier.width(8.dp)) + Column( + Modifier.defaultMinSize(minHeight = 54.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + chatLink.displayName, + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + val fn = chatLink.fullName + if (fn.isNotEmpty() && fn != chatLink.displayName) { + Text(fn, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) + Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + chatLink.shortDescription?.let { descr -> + Text( + descr, + color = MaterialTheme.colors.secondary, + fontSize = 13.sp, + lineHeight = 18.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + chatLink.infoLine(signed = ownerSig != null), + color = MaterialTheme.colors.secondary, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + Text( + stringResource(MR.strings.tap_to_open), + color = MaterialTheme.colors.primary, + ) + } + } +} 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 542623028a..afd55ed928 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 @@ -127,7 +127,8 @@ fun CIFileView( fun fileIndicator() { Box( Modifier - .size(42.sp.toDp() * sizeMultiplier) + .padding(top = 2.sp.toDp()) + .size(40.sp.toDp() * sizeMultiplier) .clip(RoundedCornerShape(4.sp.toDp() * sizeMultiplier)), contentAlignment = Alignment.Center ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 39bb9545e1..9b8393f66a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* @@ -52,15 +53,12 @@ fun CIGroupInvitationView( else if (isInDarkTheme()) FileDark else FileLight Row( - Modifier - .defaultMinSize(minWidth = 220.dp) - .padding(vertical = 4.dp) - .padding(end = 2.dp) + Modifier.defaultMinSize(minWidth = 220.dp) ) { - ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled, color = iconColor) - Spacer(Modifier.padding(horizontal = 3.dp)) + ProfileImage(size = 54.dp, image = groupInvitation.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled, color = iconColor) + Spacer(Modifier.width(8.dp)) Column( - Modifier.defaultMinSize(minHeight = 60.dp), + Modifier.defaultMinSize(minHeight = 54.dp), verticalArrangement = Arrangement.Center ) { Text(p.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis) @@ -98,8 +96,7 @@ fun CIGroupInvitationView( Box( Modifier .width(IntrinsicSize.Min) - .padding(vertical = 3.dp) - .padding(start = 8.dp, end = 12.dp), + .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), contentAlignment = Alignment.BottomEnd ) { Box( @@ -112,10 +109,10 @@ fun CIGroupInvitationView( ) { groupInfoView() val secondaryColor = MaterialTheme.colors.secondary - Column(Modifier.padding(top = 2.dp, start = 5.dp)) { - Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) + Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) + Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { if (action) { - Text(groupInvitationStr()) + Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) Text( buildAnnotatedString { append(generalGetString(if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join)) @@ -131,7 +128,9 @@ fun CIGroupInvitationView( buildAnnotatedString { append(groupInvitationStr()) withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } - } + }, + fontSize = 13.sp, + lineHeight = 18.sp, ) } } 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 8aab0bbbb6..f2788715fe 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 @@ -23,6 +23,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -55,7 +56,7 @@ fun FramedItemView( } @Composable - fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean) { + fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean, stripLink: String? = null, prefix: AnnotatedString? = null) { MarkdownText( qi.text, qi.formattedText, @@ -66,11 +67,13 @@ fun FramedItemView( linkMode = linkMode, uriHandler = if (appPlatform.isDesktop) uriHandler else null, showTimestamp = showTimestamp, + prefix = prefix, + stripLink = stripLink, ) } @Composable - fun ciQuotedMsgView(qi: CIQuote) { + fun ciQuotedMsgView(qi: CIQuote, stripLink: String? = null, prefix: AnnotatedString? = null) { Box( Modifier // this width limitation prevents crash on calculating constraints that may happen if you post veeeery long message and then quote it. @@ -89,10 +92,10 @@ fun FramedItemView( style = TextStyle(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary), maxLines = 1 ) - ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp) + ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp, stripLink = stripLink, prefix = prefix) } } else { - ciQuotedMsgTextView(qi, lines = 3, showTimestamp = showTimestamp) + ciQuotedMsgTextView(qi, lines = 3, showTimestamp = showTimestamp, stripLink = stripLink, prefix = prefix) } } } @@ -177,6 +180,20 @@ fun FramedItemView( tint = if (isInDarkTheme()) FileDark else FileLight ) } + is MsgContent.MCChat -> { + val prefix = buildAnnotatedString { + append(qi.content.chatLink.displayName + if (qi.content.text != qi.content.chatLink.connLinkStr) " - " else "") + } + Box(Modifier.fillMaxWidth().weight(1f)) { + ciQuotedMsgView(qi, stripLink = qi.content.chatLink.connLinkStr, prefix = prefix) + } + Icon( + painterResource(qi.content.chatLink.smallIconRes), + null, + Modifier.padding(top = 6.dp, end = 4.dp).size(22.dp), + tint = if (isInDarkTheme()) FileDark else FileLight + ) + } else -> ciQuotedMsgView(qi) } } @@ -329,6 +346,22 @@ fun FramedItemView( CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } + is MsgContent.MCChat -> { + val hasText = mc.text != mc.chatLink.connLinkStr + Box( + Modifier.combinedClickable( + onClick = { + withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } + }, + onLongClick = { showMenu.value = true } + ) + ) { + CIChatLinkHeader(chatLink = mc.chatLink, ownerSig = mc.ownerSig, hasText = hasText) + } + if (hasText) { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, stripLink = mc.chatLink.connLinkStr) + } + } is MsgContent.MCReport -> { val prefix = buildAnnotatedString { withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { @@ -366,7 +399,8 @@ fun CIMarkdownText( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean, showTimestamp: Boolean, - prefix: AnnotatedString? = null + prefix: AnnotatedString? = null, + stripLink: String? = null ) { val chatInfo = chat.chatInfo val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text @@ -382,6 +416,7 @@ fun CIMarkdownText( else -> null }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, + stripLink = stripLink, selectionRange = selection.highlightRange, onTextLayoutResult = selection.onTextLayoutResult ) 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 b8691c70f8..9db87b156d 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 @@ -109,9 +109,12 @@ fun MarkdownText ( showViaProxy: Boolean = false, showTimestamp: Boolean = true, prefix: AnnotatedString? = null, + stripLink: String? = null, selectionRange: IntRange? = null, onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null ) { + val text = if (stripLink != null) stripTextLink(text.toString(), stripLink) else text + val formattedText = if (stripLink != null) stripFormattedTextLink(formattedText, stripLink) else formattedText val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } @@ -532,3 +535,20 @@ private fun isRtl(s: CharSequence): Boolean { } fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" + +fun stripTextLink(text: String, link: String): String = + if (text == link) "" + else if (text.endsWith("\n$link")) text.dropLast(link.length + 1) + else text + +fun stripFormattedTextLink(ft: List?, link: String): List? { + if (ft == null || ft.isEmpty() || ft.last().text != link) return ft + val result = ft.toMutableList() + result.removeLast() + val i = result.lastIndex + if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) { + result[i] = FormattedText(result[i].text.dropLast(1), null) + if (result[i].text.isEmpty()) result.removeLast() + } + return result.ifEmpty { null } +} 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 f5e0389043..346e9bac95 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 @@ -31,6 +31,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.common.views.chat.item.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -241,12 +242,18 @@ fun ChatPreviewView( Text(previewText.first, color = previewText.second) } else if (ci != null && showChatPreviews) { val (text: CharSequence, inlineTextContent) = when { - ci.meta.itemDeleted == null -> ci.text(chat.chatInfo.isChannel) to null - else -> markedDeletedText(ci, chat.chatInfo) to null + ci.meta.itemDeleted != null -> markedDeletedText(ci, chat.chatInfo) to null + ci.content.msgContent is MsgContent.MCChat -> { + val chatLink = (ci.content.msgContent as MsgContent.MCChat).chatLink + val descr = chatLink.shortDescription?.let { "\n$it" } ?: "" + (chatLink.displayName + descr) to null + } + else -> ci.text(chat.chatInfo.isChannel) to null } - val formattedText = when { - ci.meta.itemDeleted == null -> ci.formattedText - else -> null + val formattedText: List? = when { + ci.meta.itemDeleted != null -> null + ci.content.msgContent is MsgContent.MCChat -> null + else -> ci.formattedText } val prefix = when (val mc = ci.content.msgContent) { is MsgContent.MCReport -> @@ -332,6 +339,19 @@ fun ChatPreviewView( withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } } + is MsgContent.MCChat -> SmallContentPreview(borderColor = if (mc.chatLink.image != null) MaterialTheme.colors.onSurface.copy(alpha = 0.12f) else Color.Transparent) { + Box( + Modifier.fillMaxSize().clickable { withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } }, + contentAlignment = Alignment.Center + ) { + val image = mc.chatLink.image + if (image != null) { + Image(base64ToBitmap(image), null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } else { + Icon(painterResource(mc.chatLink.iconRes), null, Modifier.size(44.sp.toDp()), tint = if (isInDarkTheme()) FileDark else FileLight) + } + } + } else -> {} } } @@ -500,8 +520,8 @@ fun ChatPreviewView( } @Composable -private fun SmallContentPreview(content: @Composable BoxScope.() -> Unit) { - Box(Modifier.padding(top = 2.sp.toDp(), end = 8.sp.toDp()).size(36.sp.toDp()).border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(22)).clip(RoundedCornerShape(22))) { +private fun SmallContentPreview(borderColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), content: @Composable BoxScope.() -> Unit) { + Box(Modifier.padding(top = 2.sp.toDp(), end = 8.sp.toDp()).size(36.sp.toDp()).border(0.5.dp, borderColor, RoundedCornerShape(22)).clip(RoundedCornerShape(22))) { content() } } 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 aa9847c98a..cf92ec2f49 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 @@ -51,6 +51,9 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) { } } } + is SharedContent.ChatLink -> { + hasSimplexLink = true + } null -> {} } if (chatModel.chats.value.isNotEmpty()) { @@ -98,7 +101,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal val navButton: @Composable RowScope.() -> Unit = { when { showSearch -> NavigationButtonBack(hideSearchOnBack) - (users.size > 1 || chatModel.remoteHosts.isNotEmpty()) && remember { chatModel.sharedContent }.value !is SharedContent.Forward -> { + (users.size > 1 || chatModel.remoteHosts.isNotEmpty()) && remember { chatModel.sharedContent }.value !is SharedContent.Forward && remember { chatModel.sharedContent }.value !is SharedContent.ChatLink -> { val allRead = users .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } @@ -129,6 +132,8 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal chatModel.sharedContent.value = null if (sharedContent is SharedContent.Forward) { chatModel.chatId.value = sharedContent.fromChatInfo.id + } else if (sharedContent is SharedContent.ChatLink) { + chatModel.chatId.value = sharedContent.groupInfo.id } }) } @@ -144,6 +149,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal is SharedContent.Media -> stringResource(MR.strings.share_image) is SharedContent.File -> stringResource(MR.strings.share_file) is SharedContent.Forward -> if (v.chatItems.size > 1) stringResource(MR.strings.forward_multiple) else stringResource(MR.strings.forward_message) + is SharedContent.ChatLink -> stringResource(MR.strings.share_channel) null -> stringResource(MR.strings.share_message) }, color = MaterialTheme.colors.onBackground, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 6bfe8d7869..3d670d1c43 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -273,6 +273,7 @@ class AlertManager { profileFullName: String, profileImage: @Composable () -> Unit, subtitle: String? = null, + information: String? = null, confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat), onConfirm: (() -> Unit)? = null, dismissText: String = generalGetString(MR.strings.cancel_verb), @@ -329,6 +330,16 @@ class AlertManager { modifier = Modifier.fillMaxWidth() ) } + if (information != null) { + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + Text( + information, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + } } Column( 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 30811d5c94..cf3281f776 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 @@ -15,6 +15,7 @@ sealed class 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 chatItems: List, val fromChatInfo: ChatInfo): SharedContent() + data class ChatLink(val groupInfo: GroupInfo): SharedContent() } enum class AnimatedViewState { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt index 944cd70255..2f030ef1dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -65,7 +65,7 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit withBGApi { openGroupChat(null, gInfo.groupId) ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, rhId = null, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, close = close) + GroupLinkView(chatModel, rhId = null, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, shareGroupInfo = gInfo, close = close) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index e83d26c394..b38fbf9f51 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -24,6 +24,7 @@ enum class ConnectionLinkType { suspend fun planAndConnect( rhId: Long?, shortOrFullLink: String, + linkOwnerSig: LinkOwnerSig? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? = null, filterKnownContact: ((Contact) -> Unit)? = null, @@ -44,12 +45,13 @@ suspend fun planAndConnect( inProgress.value = false cleanup?.invoke() } - return planAndConnectTask(rhId, shortOrFullLink, close, cleanup, filterKnownContact, filterKnownGroup, inProgress) + return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, inProgress) } private suspend fun planAndConnectTask( rhId: Long?, shortOrFullLink: String, + linkOwnerSig: LinkOwnerSig? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? = null, filterKnownContact: ((Contact) -> Unit)? = null, @@ -66,7 +68,7 @@ private suspend fun planAndConnectTask( cleanup?.invoke() completable.complete(!completable.isActive) } - val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, inProgress = inProgress) + val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, linkOwnerSig, inProgress = inProgress) connectProgressManager.stopConnectProgress() if (!inProgress.value) { return completable } if (result != null) { @@ -85,6 +87,7 @@ private suspend fun planAndConnectTask( rhId, connectionLink, connectionPlan.invitationLinkPlan.contactSLinkData_, + ownerVerification = connectionPlan.invitationLinkPlan.ownerVerification, close, cleanup ) @@ -96,6 +99,7 @@ private suspend fun planAndConnectTask( text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, connectDestructive = false, cleanup = cleanup, + ownerVerification = connectionPlan.invitationLinkPlan.ownerVerification, ) } InvitationLinkPlan.OwnLink -> { @@ -146,6 +150,7 @@ private suspend fun planAndConnectTask( rhId, connectionLink, connectionPlan.contactAddressPlan.contactSLinkData_, + ownerVerification = connectionPlan.contactAddressPlan.ownerVerification, close, cleanup ) @@ -157,6 +162,7 @@ private suspend fun planAndConnectTask( text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, connectDestructive = false, cleanup, + ownerVerification = connectionPlan.contactAddressPlan.ownerVerification, ) } ContactAddressPlan.OwnLink -> { @@ -215,6 +221,7 @@ private suspend fun planAndConnectTask( connectionLink, connectionPlan.groupLinkPlan.groupSLinkInfo_, connectionPlan.groupLinkPlan.groupSLinkData_, + ownerVerification = connectionPlan.groupLinkPlan.ownerVerification, close, cleanup ) @@ -226,6 +233,7 @@ private suspend fun planAndConnectTask( text = generalGetString(MR.strings.you_will_join_group) + linkText, connectDestructive = false, cleanup = cleanup, + ownerVerification = connectionPlan.groupLinkPlan.ownerVerification, ) } is GroupLinkPlan.OwnLink -> { @@ -292,7 +300,7 @@ private suspend fun planAndConnectTask( ProfileImage( size = alertProfileImageSize, image = groupSLinkData.groupProfile.image, - icon = MR.images.ic_bigtop_updates_padded + icon = MR.images.ic_bigtop_updates_circle_filled ) }, subtitle = generalGetString(MR.strings.channel_no_active_relays_try_later), @@ -375,10 +383,12 @@ fun askCurrentOrIncognitoProfileAlert( text: String? = null, connectDestructive: Boolean, cleanup: (() -> Unit)?, + ownerVerification: OwnerVerification? = null, ) { + val fullText = listOfNotNull(text, ownerVerificationMessage(ownerVerification)).joinToString("\n\n").ifEmpty { null } AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = title, - text = text, + text = fullText, buttons = { Column { val connectColor = if (connectDestructive) MaterialTheme.colors.error else MaterialTheme.colors.primary @@ -573,6 +583,7 @@ fun showPrepareContactAlert( rhId: Long?, connectionLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, + ownerVerification: OwnerVerification? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { @@ -589,6 +600,7 @@ fun showPrepareContactAlert( else MR.images.ic_account_circle_filled ) }, + information = ownerVerificationMessage(ownerVerification), confirmText = generalGetString(MR.strings.connect_plan_open_new_chat), onConfirm = { AlertManager.privacySensitive.hideAlert() @@ -614,6 +626,7 @@ fun showPrepareGroupAlert( connectionLink: CreatedConnLink, groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, + ownerVerification: OwnerVerification? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { @@ -626,10 +639,11 @@ fun showPrepareGroupAlert( ProfileImage( size = alertProfileImageSize, image = groupShortLinkData.groupProfile.image, - icon = if (isChannel) MR.images.ic_bigtop_updates_padded else MR.images.ic_supervised_user_circle_filled + icon = if (isChannel) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled ) }, subtitle = subscriberCount, + information = ownerVerificationMessage(ownerVerification), confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_new_group), onConfirm = { AlertManager.privacySensitive.hideAlert() @@ -657,3 +671,9 @@ fun showPrepareGroupAlert( } ) } + +fun ownerVerificationMessage(ov: OwnerVerification?): String? = when (ov) { + is OwnerVerification.Verified -> generalGetString(MR.strings.owner_verification_passed) + is OwnerVerification.Failed -> String.format(generalGetString(MR.strings.owner_verification_failed), ov.reason) + null -> null +} 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 a106b53176..0630dffe22 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -533,8 +533,21 @@ Share file… Forward message… Forward messages… + Share channel… Cannot send message Selected chat preferences prohibit this message. + Share via chat + Tap to open + Channel link + Group link + Business address + Contact address + One-time link + (from owner) + (signed) + Error sharing channel + Link signature verified. + ⚠️ Signature verification failed: %s. Attach diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg new file mode 100644 index 0000000000..c88692fc12 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg @@ -0,0 +1 @@ + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg deleted file mode 100644 index 9f4edcfd98..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/plans/2026-04-17-kotlin-share-channel-link.md b/plans/2026-04-17-kotlin-share-channel-link.md new file mode 100644 index 0000000000..0133ca377c --- /dev/null +++ b/plans/2026-04-17-kotlin-share-channel-link.md @@ -0,0 +1,561 @@ +# Kotlin/Desktop — Share chat card (MCChat) — Implementation Plan + +Port of iOS commit `f49d98511` to Kotlin multiplatform codebase. Every section maps an iOS change to its Kotlin equivalent with file:line anchors. + +--- + +## 1. Types — `ChatModel.kt` + +### 1.1 Add `LinkOwnerSig` (new type, near line 4551 after `MsgChatLink`) + +```kotlin +@Serializable +data class LinkOwnerSig( + val ownerId: String? = null, + val chatBinding: String, + val ownerSig: String +) +``` + +iOS equivalent: `ChatTypes.swift` `LinkOwnerSig` struct. + +### 1.2 Add `ownerSig` to `MCChat` (line ~4310) + +Current: `class MCChat(override val text: String, val chatLink: MsgChatLink): MsgContent()` +Change to: `class MCChat(override val text: String, val chatLink: MsgChatLink, val ownerSig: LinkOwnerSig? = null): MsgContent()` + +### 1.3 Add `chatLinkStr` property to `MsgContent` (near `text` property) + +```kotlin +val chatLinkStr: String? + get() = (this as? MCChat)?.chatLink?.connLinkStr +``` + +### 1.4 Update `MsgContentSerializer` (lines 4366-4496) + +In the `"chat"` case of the deserializer, add `ownerSig` field: +```kotlin +"chat" -> { + val text = json["text"]?.jsonPrimitive?.content ?: "" + val chatLink = Json.decodeFromJsonElement(json["chatLink"]!!) + val ownerSig = json["ownerSig"]?.let { Json.decodeFromJsonElement(it) } + MCChat(text, chatLink, ownerSig) +} +``` + +In the serializer, add `ownerSig` to the `MCChat` case: +```kotlin +is MCChat -> buildJsonObject { + put("type", "chat") + put("text", mc.text) + put("chatLink", Json.encodeToJsonElement(mc.chatLink)) + mc.ownerSig?.let { put("ownerSig", Json.encodeToJsonElement(it)) } +} +``` + +### 1.5 Add computed properties to `MsgChatLink` (line ~4547) + +The existing `MsgChatLink` sealed class uses `@SerialName` annotations for JSON. The Haskell side uses `taggedObjectJSON` format (`{"type": "group", ...}`). Need to verify the existing `@SerialName` produces the right format — it should, since kotlinx.serialization with `classDiscriminator = "type"` matches. + +Add after the sealed class definition: +```kotlin +sealed class MsgChatLink { + // ... existing cases ... + + val isPublicGroup: Boolean + get() = (this as? Group)?.groupProfile?.publicGroup != null + + val connLinkStr: String + get() = when (this) { + is Group -> connLink + is Contact -> connLink + is Invitation -> invLink + } + + val image: String? + get() = when (this) { + is Group -> groupProfile.image + is Contact -> profile.image + is Invitation -> profile.image + } + + val displayName: String + get() = when (this) { + is Group -> groupProfile.displayName + is Contact -> profile.displayName + is Invitation -> profile.displayName + } + + val fullName: String + get() = when (this) { + is Group -> groupProfile.fullName + is Contact -> profile.fullName + is Invitation -> profile.fullName + } + + val shortDescription: String? + get() { + val s = when (this) { + is Group -> groupProfile.shortDescr + is Contact -> profile.shortDescr + is Invitation -> profile.shortDescr + } + return s?.trim()?.ifEmpty { null } + } + + val iconRes: ImageResource // for ProfileImage icon parameter + get() = when (this) { + is Group -> when (groupProfile.publicGroup?.groupType) { + GroupType.Channel -> MR.images.ic_bigtop_updates_padded + else -> MR.images.ic_supervised_user_circle_filled + } + is Contact -> if (business) MR.images.ic_work_filled_padded else MR.images.ic_account_circle_filled + is Invitation -> MR.images.ic_account_circle_filled + } + + val smallIconRes: ImageResource // for inline icon in context/quote views + get() = when (this) { + is Group -> when (groupProfile.publicGroup?.groupType) { + GroupType.Channel -> MR.images.ic_bigtop_updates + else -> MR.images.ic_group + } + is Contact -> if (business) MR.images.ic_work else MR.images.ic_person + is Invitation -> MR.images.ic_person + } + + fun infoLine(signed: Boolean): String { + var s = when (this) { + is Group -> when (groupProfile.publicGroup?.groupType) { + GroupType.Channel -> generalGetString(MR.strings.channel_link) + else -> generalGetString(MR.strings.group_link) + } + is Contact -> if (business) generalGetString(MR.strings.business_address) else generalGetString(MR.strings.contact_address) + is Invitation -> generalGetString(MR.strings.one_time_link) + } + if (signed) { + s += " " + if (isPublicGroup) generalGetString(MR.strings.from_owner) else generalGetString(MR.strings.signed_parentheses) + } + return s + } +} +``` + +Icons resolved — see "Resolved decisions" section 1. + +### 1.6 Add `OwnerVerification` type (near ConnectionPlan, line ~6844 of SimpleXAPI.kt) + +```kotlin +@Serializable +sealed class OwnerVerification { + @Serializable @SerialName("verified") object Verified : OwnerVerification() + @Serializable @SerialName("failed") class Failed(val reason: String) : OwnerVerification() +} +``` + +### 1.7 Update plan types with `ownerVerification` (SimpleXAPI.kt lines 6852-6877) + +- `InvitationLinkPlan.Ok`: add `val ownerVerification: OwnerVerification? = null` +- `ContactAddressPlan.Ok`: add `val ownerVerification: OwnerVerification? = null` +- `GroupLinkPlan.Ok`: add `val ownerVerification: OwnerVerification? = null` + +--- + +## 2. API commands — `SimpleXAPI.kt` + +### 2.1 Add `ApiShareChatMsgContent` command class (near line 3626) + +```kotlin +class ApiShareChatMsgContent( + val shareChatType: ChatType, val shareChatId: Long, + val toChatType: ChatType, val toChatId: Long, + val toScope: GroupChatScope?, val sendAsGroup: Boolean +): CC() +``` + +Add `cmdString`: +```kotlin +is ApiShareChatMsgContent -> { + val asGroup = if (sendAsGroup) "(as_group=on)" else "" + "/_share chat content ${chatRef(shareChatType, shareChatId)} ${chatRef(toChatType, toChatId, toScope)}$asGroup" +} +``` + +### 2.2 Add `CR.ChatMsgContent` response (near line 6320) + +```kotlin +@Serializable @SerialName("chatMsgContent") +class ChatMsgContent(val user: UserRef, val msgContent: MsgContent): CR() +``` + +### 2.3 Add `apiShareChatMsgContent` wrapper function (near line 1133) + +```kotlin +suspend fun apiShareChatMsgContent( + rh: Long?, shareChatType: ChatType, shareChatId: Long, + toChatType: ChatType, toChatId: Long, + toScope: GroupChatScope?, sendAsGroup: Boolean +): MsgContent? { + val r = sendCmd(rh, CC.ApiShareChatMsgContent(shareChatType, shareChatId, toChatType, toChatId, toScope, sendAsGroup)) + if (r is CR.ChatMsgContent) return r.msgContent + apiErrorAlert("apiShareChatMsgContent", r) + return null +} +``` + +### 2.4 Update `apiConnectPlan` (line 1488) + +Add `linkOwnerSig: LinkOwnerSig? = null` parameter. Update the `CC.APIConnectPlan` class to include it. Update cmdString to append `sig=` when present. + +--- + +## 3. Compose state — `ComposeView.kt` + `Enums.kt` + +### 3.1 Add `SharedContent.ChatLink` to `Enums.kt` (line 13-18) + +```kotlin +data class ChatLink(val groupInfo: GroupInfo): SharedContent() +``` + +This triggers the share flow: sets `chatModel.sharedContent.value = SharedContent.ChatLink(groupInfo)` → navigates to chat list → user picks destination. + +### 3.2 Add `ChatLinkPreview` to `ComposePreview` (`ComposeView.kt` line 57-63) + +```kotlin +@Serializable class ChatLinkPreview(val chatLink: MsgChatLink, val ownerSig: LinkOwnerSig?): ComposePreview() +``` + +### 3.3 Update `ComposeState` (`ComposeView.kt` line 103-240) + +- `sendEnabled`: add `is ComposePreview.ChatLinkPreview -> true` case +- `linkPreviewAllowed`: add `is ComposePreview.ChatLinkPreview -> false` +- `attachmentPreview`: add `is ComposePreview.ChatLinkPreview -> false` + +### 3.4 Add compose preview rendering + +In the compose area where previews are rendered, add a case for `ChatLinkPreview` that shows `ComposeChatLinkView` (new composable). + +### 3.5 Add send handling + +In the send function, add case for `ChatLinkPreview`: +```kotlin +is ComposePreview.ChatLinkPreview -> { + val linkStr = preview.chatLink.connLinkStr + val text = if (msgText.isEmpty()) linkStr else "$msgText\n$linkStr" + send(MsgContent.MCChat(text, preview.chatLink, preview.ownerSig), ...) +} +``` + +### 3.6 Handle `SharedContent.ChatLink` in `ComposeView.kt` (line 1431-1446, `LaunchedEffect(chatModel.sharedContent.value)`) + +When the destination chat opens with `SharedContent.ChatLink`, the `LaunchedEffect` fires. At this point: +- `chatModel.chatId.value` = destination chat ID +- `shared.groupInfo` = source group (what we're sharing) +- The current chat's `ChatInfo` provides destination type/id/scope for the API call + +```kotlin +is SharedContent.ChatLink -> { + // chat variable is available in ComposeView scope — it's the destination chat + val cInfo = chat.chatInfo + val sendAsGroup = cInfo.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false + withBGApi { + val mc = chatModel.controller.apiShareChatMsgContent( + chat.remoteHostId, ChatType.Group, shared.groupInfo.groupId, + cInfo.chatType, cInfo.apiId, + cInfo.groupChatScope(), sendAsGroup + ) + if (mc is MsgContent.MCChat) { + composeState.value = composeState.value.copy( + preview = ComposePreview.ChatLinkPreview(mc.chatLink, mc.ownerSig) + ) + } else if (mc != null) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_sharing_channel), + mc.toString() + ) + } + } +} +``` + +Note: `chat` is available as a parameter in the ComposeView composable scope. `withBGApi` is needed because `apiShareChatMsgContent` is a suspend function and `LaunchedEffect` already runs in a coroutine but the API call should use the standard error handling pattern. + +### 3.7 Handle `SharedContent.ChatLink` in `ShareListView.kt` (line 33-54) + +Add filtering case in the `when (sharedContent)` block: +```kotlin +is SharedContent.ChatLink -> { + hasSimplexLink = true // chat cards ARE simplex links, prohibited by SimplexLinks group pref +} +``` + +This means in `ShareListNavLinkView` (line 44): `simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)` — groups where simplex links are disabled will show as prohibited (disabled row + alert on tap). Direct chats and local notes are unaffected (line 30-31 don't check simplex links for direct). + +### 3.8 Handle `SharedContent.ChatLink` in `ShareListNavLinkView.kt` (line 28-67) + +The existing `when (chat.chatInfo)` dispatch handles click actions per chat type. For `SharedContent.ChatLink`, the click action (line 37 `directChatAction`, line 54 `groupChatAction`) opens the destination chat. `ComposeView`'s `LaunchedEffect` (§3.6) then picks up the `SharedContent.ChatLink` and sets up the compose preview. + +No changes needed to `ShareListNavLinkView` click handlers — they already open the correct chat. The `SharedContent.ChatLink` is consumed by `ComposeView`. + +### 3.9 Handle `SharedContent.ChatLink` in `ShareListToolbar` (line 142-147) + +Add title for the share list toolbar: +```kotlin +is SharedContent.ChatLink -> stringResource(MR.strings.share_channel) +``` + +### 3.10 Handle back navigation from share list with `SharedContent.ChatLink` (line 126-133) + +When user taps back on the share list with `SharedContent.ChatLink`, should navigate back to the source chat (like Forward navigates back to `fromChatInfo.id`): +```kotlin +if (sharedContent is SharedContent.ChatLink) { + chatModel.chatId.value = sharedContent.groupInfo.id +} +``` + +--- + +## 4. New composables + +### 4.1 `ComposeChatLinkView.kt` (new file) + +Near `ComposeView.kt`. Shows ProfileImage + displayName + optional shortDescription. Cancel button. Mirrors iOS `ComposeChatLinkView`. + +### 4.2 `CIChatLinkHeader.kt` (new file) + +Near `FramedItemView.kt`. Shows profile header (image + name + fullName), shortDescription, info line, "Tap to open" + meta. Mirrors iOS `CIChatLinkHeader`. + +--- + +## 5. Message rendering — `FramedItemView.kt` + +### 5.1 Add `MCChat` case in content dispatch (line ~296-341) + +After the `MCLink` case: +```kotlin +is MsgContent.MCChat -> { + val hasText = mc.text != mc.chatLink.connLinkStr + CIChatLinkHeader(chatItem = ci, chatLink = mc.chatLink, ownerSig = mc.ownerSig, hasText = hasText) + // tap gesture → planAndConnect(mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig) + if (hasText) { + CIMarkdownText(..., stripLink = mc.chatLink.connLinkStr) + } +} +``` + +### 5.2 Add `MCChat` case in quote dispatch (line ~142-183) + +```kotlin +is MsgContent.MCChat -> { + val prefix = buildAnnotatedString { + append(mc.chatLink.displayName) + append(if (mc.text != mc.chatLink.connLinkStr) " - " else "") + } + CIQuotedMsgView(qi, stripLink = mc.chatLink.connLinkStr, prefix = prefix) + // + small icon +} +``` + +### 5.3 Add `stripLink` parameter to text rendering + +`CIMarkdownText` / `MarkdownText` (TextItemView.kt) needs `stripLink: String? = null` parameter. Inside, strip the text and formattedText before rendering. + +Add `stripTextLink` and `stripFormattedTextLink` functions near `MarkdownText`: + +```kotlin +fun stripTextLink(text: String, link: String): String = + if (text == link) "" + else if (text.endsWith("\n$link")) text.dropLast(link.length + 1) + else text + +fun stripFormattedTextLink(ft: List?, link: String): List? { + if (ft == null || ft.isEmpty() || ft.last().text != link) return ft + val result = ft.toMutableList() + result.removeLast() + val i = result.lastIndex + if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) { + result[i] = result[i].copy(text = result[i].text.dropLast(1)) + if (result[i].text.isEmpty()) result.removeLast() + } + return result.ifEmpty { null } +} +``` + +--- + +## 6. Chat list preview — `ChatPreviewView.kt` + +### 6.1 Add content preview for `MCChat` (line ~293-337) + +```kotlin +is MsgContent.MCChat -> { + SmallContentPreview(borderColor = if (mc.chatLink.image != null) ...) { + ProfileImage(mc.chatLink.image, mc.chatLink.iconName, size) + // onClick → planAndConnect + } +} +``` + +### 6.2 Update text preview (line ~217-290) + +For `MCChat`, show `displayName + description` instead of raw text: +```kotlin +is MsgContent.MCChat -> { + val descr = mc.chatLink.shortDescription?.let { "\n$it" } ?: "" + itemText = mc.chatLink.displayName + descr + formattedText = null +} +``` + +--- + +## 7. Context/forwarding view — `ContextItemView.kt` + +### 7.1 Add `MCChat` attachment icon (line 75-84, `fun attachment()`) + +```kotlin +is MsgContent.MCChat -> mc.chatLink.smallIconRes +``` + +This returns the small icon (e.g., `MR.images.ic_bigtop_updates` for channels). The icon is rendered inline via the existing `inlineContent` mechanism in `MessageText` (line 42-58). + +### 7.2 Add `MCChat` case in `ContextMsgPreview` or `MessageText` (line 87-89) + +For MCChat, `MessageText` needs: +1. The attachment icon (from §7.1) — rendered inline by existing mechanism +2. `prefix` with `chatLink.displayName + " - "` (or just displayName if no text) — `MarkdownText` already has `prefix: AnnotatedString?` +3. `stripLink = chatLink.connLinkStr` — strips the link from text + +Modify `ContextMsgPreview` (line 87-89) or add a special case: +```kotlin +fun ContextMsgPreview(contextItem: ChatItem, lines: Int) { + val mc = contextItem.content.msgContent + if (mc is MsgContent.MCChat) { + val hasText = contextItem.text != mc.chatLink.connLinkStr + val prefix = buildAnnotatedString { append(mc.chatLink.displayName + if (hasText) " - " else "") } + MessageText(contextItem, mc.chatLink.smallIconRes, lines, prefix = prefix, stripLink = mc.chatLink.connLinkStr) + } else { + MessageText(contextItem, remember(contextItem.id) { attachment(contextItem) }, lines) + } +} +``` + +This requires `MessageText` to accept optional `prefix` and `stripLink` parameters and pass them to `MarkdownText`. + +--- + +## 8. Group link / info views + +### 8.1 `GroupLinkView.kt` (line ~27) + +Add parameter: `groupInfo: GroupInfo? = null`. +Add "Share via chat" button when `groupInfo?.groupProfile?.publicGroup != null`. +Button action: `chatModel.sharedContent.value = SharedContent.ChatLink(groupInfo)` + close modals + navigate to chat list. + +### 8.2 `GroupChatInfoView.kt` + +Add "Share via chat" button in the channel link section (next to existing "Share link" button). +Button action: same as 8.1 — sets `SharedContent.ChatLink` and navigates. + +No `composeState` parameter needed (unlike iOS) — the `SharedContent` pattern handles state transfer without bindings. + +### 8.3 Channel creation (equivalent of `AddChannelView`) + +Find the Kotlin channel creation flow and pass `groupInfo` to `GroupLinkView` so "Share via chat" is available during creation. + +### 8.4 Share flow summary (no separate `shareChatLink` function needed) + +Unlike iOS which has a separate `shareChatLink` free function (due to sheet-based navigation), Kotlin's flow is: + +1. User taps "Share via chat" → `chatModel.sharedContent.value = SharedContent.ChatLink(groupInfo)` + `chatModel.chatId.value = null` (navigates to chat list showing `ShareListView`) +2. `ShareListView` shows filtered chats with `hasSimplexLink = true` prohibition +3. User picks destination → `directChatAction`/`groupChatAction` opens the chat +4. `ComposeView`'s `LaunchedEffect` fires (§3.6) → calls `apiShareChatMsgContent` → sets `ComposePreview.ChatLinkPreview` +5. User types optional text, taps Send +6. Send dispatch (§3.5) constructs `MCChat(text + link, chatLink, ownerSig)` and sends + +The API call happens in `ComposeView`'s `LaunchedEffect`, not in a separate function. Error handling: if the API fails, show alert and clear `sharedContent`. + +For the **channel creation flow** (no chat open yet): when `SharedContent.ChatLink` is consumed in `ComposeView` and the API call succeeds, the preview is set directly. No draft fallback needed — the chat IS already open at that point (the user picked it from the share list). + +--- + +## 9. Connect flow + +### 9.1 Update `planAndConnect` equivalent + +Add `linkOwnerSig: LinkOwnerSig? = null` parameter. Pass to `apiConnectPlan`. Thread `ownerVerification` from plan result to connect alerts. + +### 9.2 Add `ownerVerificationMessage` function + +```kotlin +fun ownerVerificationMessage(ov: OwnerVerification?): String? = when (ov) { + is OwnerVerification.Verified -> generalGetString(MR.strings.link_signature_verified) + is OwnerVerification.Failed -> "⚠️ " + String.format(generalGetString(MR.strings.signature_verification_failed), ov.reason) + null -> null +} +``` + +### 9.3 Update connect alerts + +Add `information: String? = null` parameter to `AlertManager.showOpenChatAlert` (`AlertManager.kt` line 271). Render as a separate `Text` below subtitle with `MaterialTheme.colors.onSurface` color (not secondary — more prominent). + +Update `showPrepareContactAlert` (ConnectPlan.kt line 572) and `showPrepareGroupAlert` (line 612) to accept and pass `ownerVerification`. Thread from `planAndConnectTask` `.Ok` cases. + +--- + +## 10. String resources + +Add to `strings.xml` (all platforms). No collisions found with existing keys: +- `chat_link_channel` = "Channel link" +- `chat_link_group` = "Group link" +- `chat_link_business_address` = "Business address" +- `chat_link_contact_address` = "Contact address" +- `chat_link_one_time` = "One-time link" +- `chat_link_from_owner` = "(from owner)" +- `chat_link_signed` = "(signed)" +- `owner_verification_passed` = "Link signature verified." +- `owner_verification_failed` = "⚠️ Signature verification failed: %s." +- `error_sharing_channel` = "Error sharing channel" +- `share_via_chat` = "Share via chat" +- `share_channel` = "Share channel" +- `tap_to_open` = "Tap to open" + +--- + +## Resolved decisions (from investigation) + +### 1. Icon resource names (verified from `ChatModel.kt` and MR/images/) +- **Channel**: `MR.images.ic_bigtop_updates_padded` (used in `GroupInfo.chatIconName` when `useRelays`) +- **Group**: `MR.images.ic_supervised_user_circle_filled` (used in `GroupInfo.chatIconName` default) +- **Business**: `MR.images.ic_work_filled_padded` (used in `GroupInfo.chatIconName` business case) +- **Contact**: `MR.images.ic_account_circle_filled` (used in `Contact.chatIconName`) +- **Small (inline text) icons**: `MR.images.ic_bigtop_updates` (channel), `MR.images.ic_group` (group), `MR.images.ic_work` (business), `MR.images.ic_person` (contact/invitation) + +### 2. MsgChatLink JSON serialization +Existing `@Serializable` sealed class with `@SerialName` annotations already produces `{"type": "group", ...}` format. No custom serializer needed (confirmed by user). Keep existing pattern. + +### 3. Forwarding/sharing picker pattern +Kotlin uses `SharedContent` + navigate to chat list, NOT a sheet picker: +- Forward: sets `chatModel.sharedContent.value = SharedContent.Forward(items, fromInfo)` + `chatModel.chatId.value = null` (returns to chat list) +- Share: add `SharedContent.ChatLink(groupInfo: GroupInfo)` case → sets `sharedContent` → user picks chat from `ShareListView` → opens chat with `ChatLinkPreview` in compose + +`ShareListView.kt` (line 44) dispatches on `SharedContent` type for filtering. Add `SharedContent.ChatLink` case there with `hasSimplexLink = true` filtering. + +### 4. Navigation after share +- `ModalManager.closeAllModalsEverywhere()` dismisses all modals +- Setting `chatModel.chatId.value = chatId` navigates to a chat +- For the share flow: `shareChatLink` calls API → on success → `ModalManager.closeAllModalsEverywhere()` → sets `composeState` preview → sets `chatModel.chatId.value = destChat.id` + +### 5. Draft mechanism (verified at `ChatModel.kt:203-204`) +Same as iOS: `chatModel.draft: MutableState` and `chatModel.draftChatId: MutableState`. Used in `ComposeView.kt:435-444` for save/restore. Same fallback pattern as iOS for the channel creation flow. + +### 6. `planAndConnect` (verified at `ConnectPlan.kt:24-48`) +Single function `suspend fun planAndConnect(rhId, shortOrFullLink, close, cleanup, filterKnownContact, filterKnownGroup)` in `ConnectPlan.kt`. Add `linkOwnerSig: LinkOwnerSig? = null` parameter. Thread to `apiConnectPlan`. Thread `ownerVerification` to alert functions. + +Alert functions: `showPrepareContactAlert` (line 572) and `showPrepareGroupAlert` (line 612) use `AlertManager.privacySensitive.showOpenChatAlert(...)` which has `subtitle: String?`. Add `information: String? = null` parameter. + +### 7. Forwarding view parameterization +No `ChatItemForwardingView` to parameterize — Kotlin uses `ShareListView` which dispatches on `SharedContent` type. Add a new `SharedContent.ChatLink` case. `ShareListView` filters chats and shows the list. When user picks a chat, `ComposeView` reads `sharedContent` and sets compose state accordingly (line 1439: `is SharedContent.Forward -> composeState.value = ...`). Add handling for `SharedContent.ChatLink`. + +### 8. String resource naming +Need to check existing strings to avoid collisions. Use `chat_link_channel`, `chat_link_group`, etc. prefix pattern to avoid collision with existing `group_link` string.