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
@@ -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