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>
This commit is contained in:
Evgeny
2026-04-19 11:26:54 +01:00
committed by GitHub
parent f49d985119
commit a2fa2be87e
28 changed files with 1105 additions and 108 deletions
@@ -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)
@@ -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)
}
}
@@ -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)
}
@@ -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<MsgContent> {
}
"chat" -> {
val chatLink = decoder.json.decodeFromString<MsgChatLink>(json["chatLink"].toString())
MsgContent.MCChat(text, chatLink)
val ownerSig = json["ownerSig"]?.let { decoder.json.decodeFromJsonElement<LinkOwnerSig>(it) }
MsgContent.MCChat(text, chatLink, ownerSig)
}
else -> MsgContent.MCUnknown(t, text, json)
}
@@ -4489,6 +4490,7 @@ object MsgContentSerializer : KSerializer<MsgContent> {
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() =
@@ -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<Long>): 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<Boolean>): Pair<CreatedConnLink, ConnectionPlan>? {
suspend fun apiConnectPlan(rh: Long?, connLink: String, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): Pair<CreatedConnLink, ConnectionPlan>? {
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<Long>): 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<Long>, 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<Long>, 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<String>): CR()
@Serializable @SerialName("chatInfoUpdated") class ChatInfoUpdated(val user: UserRef, val chatInfo: ChatInfo): CR()
@Serializable @SerialName("newChatItems") class NewChatItems(val user: UserRef, val chatItems: List<AChatItem>): CR()
@Serializable @SerialName("chatMsgContent") class ChatMsgContent(val user: UserRef, val msgContent: MsgContent): CR()
@Serializable @SerialName("chatItemsStatusesUpdated") class ChatItemsStatusesUpdated(val user: UserRef, val chatItems: List<AChatItem>): 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()
@@ -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
@@ -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)
}
}
}
@@ -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)
}
}
}
}
@@ -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<String>, val content: List<UploadContent>): 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
@@ -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<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = 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
@@ -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) {
@@ -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<GroupMemberRole?>,
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<GroupMemberRole?>, enabled: Boolean = true) {
Row(
@@ -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) {
@@ -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,
)
}
}
}
@@ -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
) {
@@ -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,
)
}
}
@@ -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
)
@@ -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<FormattedText>?, link: String): List<FormattedText>? {
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 }
}
@@ -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<FormattedText>? = 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()
}
}
@@ -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,
@@ -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(
@@ -15,6 +15,7 @@ sealed class SharedContent {
data class Media(val text: String, val uris: List<URI>): SharedContent()
data class File(val text: String, val uri: URI): SharedContent()
data class Forward(val chatItems: List<ChatItem>, val fromChatInfo: ChatInfo): SharedContent()
data class ChatLink(val groupInfo: GroupInfo): SharedContent()
}
enum class AnimatedViewState {
@@ -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)
}
}
}
@@ -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
}
@@ -533,8 +533,21 @@
<string name="share_file">Share file…</string>
<string name="forward_message">Forward message…</string>
<string name="forward_multiple">Forward messages…</string>
<string name="share_channel">Share channel…</string>
<string name="cannot_share_message_alert_title">Cannot send message</string>
<string name="cannot_share_message_alert_text">Selected chat preferences prohibit this message.</string>
<string name="share_via_chat">Share via chat</string>
<string name="tap_to_open">Tap to open</string>
<string name="chat_link_channel">Channel link</string>
<string name="chat_link_group">Group link</string>
<string name="chat_link_business_address">Business address</string>
<string name="chat_link_contact_address">Contact address</string>
<string name="chat_link_one_time">One-time link</string>
<string name="chat_link_from_owner">(from owner)</string>
<string name="chat_link_signed">(signed)</string>
<string name="error_sharing_channel">Error sharing channel</string>
<string name="owner_verification_passed">Link signature verified.</string>
<string name="owner_verification_failed">⚠️ Signature verification failed: %s.</string>
<!-- ComposeView.kt, helpers -->
<string name="attach">Attach</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path fill-rule="evenodd" d="M480-85A395 395 0 1 0 479.99-85Z M234.3-480q0 44.1 15.05 84.88T293.1-321.1q5.25 5.6 5.77 13.12T294.15-295.2q-5.6 5.6-12.25 4.2t-11.9-7q-33.6-37.45-51.45-84.52T200.7-480q0-50.4 17.85-97.47T270-662.35q5.25-5.25 11.9-6.3t12.07 4.38q5.42 5.42 4.9 12.6T293.1-639.25q-28.7 33.6-43.75 74.38T234.3-480Zm100.62 50.22q8.22 24.32 24.32 44.62 4.55 5.6 4.9 12.6t-5.07 12.42q-5.42 5.42-12.25 5.07T335.45-361q-20.3-25.2-31.32-55.82T293.1-480q0-32.55 11.02-63.17T335.45-599q4.55-5.6 11.38-6.3t12.25 4.72q5.42 5.42 5.07 12.42t-4.9 12.25q-16.1 20.3-24.32 45.15T326.7-480q0 25.9 8.22 50.22ZM460.05-193v-227.5q-19.6-5.95-31.15-22.57T417.35-480q0-26.25 18.2-44.62T480-543q26.25 0 44.62 18.38T543-480q0 20.3-11.72 36.92T500.3-420.5v227.5q0 8.75-5.77 14.35t-14.52 5.6q-8.75 0-14.35-5.6t-5.6-14.35Zm165.02-337.22Q616.85-554.55 601.1-575.2q-4.9-5.25-5.25-12.25t5.07-12.42q5.42-5.42 12.25-5.07t11.38 5.95q20.3 25.2 31.32 55.82T666.9-480q0 32.55-11.02 63.17T624.55-361q-4.55 5.6-11.02 5.95t-11.9-5.07Q596.2-365.55 596.2-372.55t4.9-12.6q15.75-20.3 23.97-44.62T633.3-480q0-25.9-8.22-50.22ZM725.7-480q0-44.1-15.05-84.88T666.9-639.25q-5.25-5.25-5.77-12.77t4.9-12.95Q671.45-670.4 678.1-669t12.25 6.65q33.25 37.8 51.1 84.88T759.3-480q0 50.4-17.85 97.47T690.35-298q-5.6 5.6-12.25 6.65t-12.07-4.38q-5.42-5.42-4.9-12.6T666.9-321.1q28.7-33.25 43.75-74.02T725.7-480Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,8 +0,0 @@
<svg width="1240" height="1240" xmlns="http://www.w3.org/2000/svg">
<g>
<g transform="translate(164, 1076) scale(0.95)">
<path d="M129-560q0 63 21.5 121.25T213-333q7.5 8 8.25 18.75T214.5-296q-8 8-17.5 6t-17-10q-48-53.5-73.5-120.75T81-560q0-72 25.5-139.25T180-820.5q7.5-7.5 17-9t17.25 6.25q7.75 7.75 7 18T213-787.5q-41 48-62.5 106.25T129-560Zm143.75 71.75q11.75 34.75 34.75 63.75 6.5 8 7 18t-7.25 17.75q-7.75 7.75-17.5 7.25T273.5-390q-29-36-44.75-79.75T213-560q0-46.5 15.75-90.25T273.5-730q6.5-8 16.25-9t17.5 6.75q7.75 7.75 7.25 17.75t-7 17.5q-23 29-34.75 64.5T261-560q0 37 11.75 71.75ZM451.5-150v-325q-28-8.5-44.5-32.25T390.5-560q0-37.5 26-63.75T480-650q37.5 0 63.75 26.25T570-560q0 29-16.75 52.75T509-475v325q0 12.5-8.25 20.5t-20.75 8q-12.5 0-20.5-8t-8-20.5Zm235.75-481.75Q675.5-666.5 653-696q-7-7.5-7.5-17.5t7.25-17.75q7.75-7.75 17.5-7.25t16.25 8.5q29 36 44.75 79.75T747-560q0 46.5-15.75 90.25T686.5-390q-6.5 8-15.75 8.5t-17-7.25Q646-396.5 646-406.5t7-18q22.5-29 34.25-63.75T699-560q0-37-11.75-71.75ZM831-560q0-63-21.5-121.25T747-787.5q-7.5-7.5-8.25-18.25t7-18.5Q753.5-832 763-830t17.5 9.5q47.5 54 73 121.25T879-560q0 72-25.5 139.25T780.5-300q-8 8-17.5 9.5t-17.25-6.25q-7.75-7.75-7-18T747-333q41-47.5 62.5-105.75T831-560Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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<MsgChatLink>(json["chatLink"]!!)
val ownerSig = json["ownerSig"]?.let { Json.decodeFromJsonElement<LinkOwnerSig>(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=<json>` 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<FormattedText>?, link: String): List<FormattedText>? {
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<ComposeState?>` and `chatModel.draftChatId: MutableState<String?>`. 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.