core, ui: chat banner (#6089)

* core: create banner item

* filter deletions

* fix query

* ios

* fixes

* remove comment

* revert diff

* refactor

* fix most tests

* fix tests

* spacer

* plans

* create banner for 1-time link initiator

* style in progress

* change background

* ui

* remove bio length limit

* ui

* create banner for client chat

* rename

* more contexts

* fix tests

* move

* fixed image size

* plans

* remove diff

* kotlin

* copy

* paddings

* paddings

* comment

* layout, messages

* fonts

* texts, icons

* kotlin refactor

* kotlin texts

* fix date

* Revert "fix date"

This reverts commit abbd48b334.

* date

* fix texts

* kotlin date

* color and corners

* kotlin

* color

* update banner, context menu in ios

* update texts, do not show epoch timestamp for banner

* fix texts

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
spaced4ndy
2025-07-21 19:07:21 +01:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent ec061bcbb9
commit 68b7f09c8f
42 changed files with 1280 additions and 206 deletions
@@ -2813,6 +2813,7 @@ data class ChatItem (
is CIContent.RcvDirectE2EEInfo -> false
is CIContent.SndGroupE2EEInfo -> false
is CIContent.RcvGroupE2EEInfo -> false
is CIContent.ChatBanner -> false
else -> true
}
@@ -2879,6 +2880,7 @@ data class ChatItem (
is CIContent.RcvDirectE2EEInfo -> false
is CIContent.SndGroupE2EEInfo -> false
is CIContent.RcvGroupE2EEInfo -> false
is CIContent.ChatBanner -> false
is CIContent.InvalidJSON -> false
}
@@ -3549,6 +3551,7 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("rcvDirectE2EEInfo") class RcvDirectE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupE2EEInfo") class SndGroupE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupE2EEInfo") class RcvGroupE2EEInfo(val e2eeInfo: E2EEInfo): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("chatBanner") object ChatBanner: CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null }
override val text: String get() = when (this) {
@@ -3582,6 +3585,7 @@ sealed class CIContent: ItemContent {
is RcvDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo)
is SndGroupE2EEInfo -> e2eeInfoNoPQStr
is RcvGroupE2EEInfo -> e2eeInfoNoPQStr
is ChatBanner -> ""
is InvalidJSON -> "invalid data"
}
@@ -883,7 +883,7 @@ object ChatController {
}
suspend fun apiStartChat(ctrl: ChatCtrl? = null): Boolean {
val r = sendCmd(null, CC.StartChat(mainApp = true, largeLinkData = false), ctrl)
val r = sendCmd(null, CC.StartChat(mainApp = true, largeLinkData = true), ctrl)
when (r.result) {
is CR.ChatStarted -> return true
is CR.ChatRunning -> return false
@@ -42,7 +42,9 @@ import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.platform.AudioPlayer
import chat.simplex.common.views.newchat.ContactConnectionInfoView
import chat.simplex.common.views.newchat.alertProfileImageSize
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.*
@@ -1754,6 +1756,127 @@ fun BoxScope.ChatItemsList(
ChatItemView(cItem, range, itemSeparation, previousItemSeparationLargeGap)
}
}
@Composable
fun ChatBannerView() {
fun chatContext(): String? {
return when (chatInfo) {
is ChatInfo.Direct -> {
val contact = chatInfo.contact
val preparedLinkType = contact.preparedContact?.uiConnLinkType
if (contact.nextConnectPrepared && preparedLinkType != null) {
when (preparedLinkType) {
ConnectionMode.Inv -> generalGetString(MR.strings.chat_banner_connect_to_chat)
ConnectionMode.Con -> generalGetString(MR.strings.chat_banner_send_request_to_connect)
}
} else if (contact.nextAcceptContactRequest) {
generalGetString(MR.strings.chat_banner_accept_contact_request)
} else {
generalGetString(MR.strings.chat_banner_your_contact)
}
}
is ChatInfo.Group -> {
val groupInfo = chatInfo.groupInfo
when (groupInfo.businessChat?.chatType) {
null -> {
if (groupInfo.nextConnectPrepared) {
generalGetString(MR.strings.chat_banner_join_group)
} else {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> generalGetString(MR.strings.chat_banner_join_group)
GroupMemberStatus.MemCreator -> generalGetString(MR.strings.chat_banner_your_group)
else -> generalGetString(MR.strings.chat_banner_group)
}
}
}
BusinessChatType.Business ->
if (groupInfo.nextConnectPrepared) {
generalGetString(MR.strings.chat_banner_connect_to_chat)
} else {
generalGetString(MR.strings.chat_banner_business_connection)
}
BusinessChatType.Customer ->
generalGetString(MR.strings.chat_banner_your_business_contact)
}
}
else -> null
}
}
Box(
Modifier
.clipChatItem()
.background(MaterialTheme.appColors.receivedMessage)
) {
val bannerModifier = if (appPlatform.isDesktop) Modifier.width(400.dp) else Modifier.fillMaxWidth()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = bannerModifier
.padding(horizontal = DEFAULT_PADDING)
.padding(bottom = DEFAULT_PADDING)
// ChatInfoImage has its own padding somewhere,
// also not doing verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF) because of it
.padding(top = DEFAULT_PADDING_HALF)
.background(MaterialTheme.appColors.receivedMessage)
) {
ChatInfoImage(chatInfo, size = alertProfileImageSize, iconColor = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f))
Text(
chatInfo.displayName,
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.widthIn(max = 240.dp)
)
val fullName = chatInfo.fullName.trim()
if (fullName.isNotEmpty() && fullName != chatInfo.displayName && fullName != chatInfo.displayName.trim()) {
Text(
fullName,
style = MaterialTheme.typography.h4,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.widthIn(max = 260.dp)
.padding(top = DEFAULT_PADDING_HALF)
)
}
val descr = chatInfo.shortDescr?.trim()
if (descr != null && descr != "") {
Text(
descr,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
lineHeight = 21.sp,
modifier = Modifier
.padding(top = DEFAULT_PADDING_HALF)
)
}
val contextStr = chatContext()
if (contextStr != null) {
Text(
contextStr,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
modifier = Modifier.padding(top = DEFAULT_PADDING)
)
}
}
}
}
LazyColumnWithScrollBar(
Modifier.align(Alignment.BottomCenter),
state = listState.value,
@@ -1768,42 +1891,62 @@ fun BoxScope.ChatItemsList(
) {
val mergedItemsValue = mergedItems.value
itemsIndexed(mergedItemsValue.items, key = { _, merged -> keyForItem(merged.newest().item) }) { index, merged ->
val isLastItem = index == mergedItemsValue.items.lastIndex
val last = if (isLastItem) reversedChatItems.value.lastOrNull() else null
val listItem = merged.newest()
val item = listItem.item
val range = if (merged is MergedItem.Grouped) {
merged.rangeInReversed.value
} else {
null
}
val showAvatar = shouldShowAvatar(item, merged.oldest().nextItem)
val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } }
val itemSeparation: ItemSeparation
val prevItemSeparationLargeGap: Boolean
if (merged is MergedItem.Single || isRevealed.value) {
val prev = listItem.prevItem
itemSeparation = getItemSeparation(item, prev)
val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem
prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item)
} else {
itemSeparation = getItemSeparation(item, null)
prevItemSeparationLargeGap = false
}
ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) {
if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems)
}
if (last != null) {
// no using separate item(){} block in order to have total number of items in LazyColumn match number of merged items
DateSeparator(last.meta.itemTs)
}
if (item.isRcvNew) {
val itemIds = when (merged) {
is MergedItem.Single -> listOf(merged.item.item.id)
is MergedItem.Grouped -> merged.items.map { it.item.id }
if (item.content is CIContent.ChatBanner) {
Column {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
.padding(bottom = 90.dp, top = DEFAULT_PADDING)
) {
ChatBannerView()
}
val prevItem = listItem.prevItem
if (prevItem != null) {
DateSeparator(prevItem.meta.itemTs)
}
}
} else {
val isLastItem = index == mergedItemsValue.items.lastIndex
val last = if (isLastItem) reversedChatItems.value.lastOrNull() else null
val range = if (merged is MergedItem.Grouped) {
merged.rangeInReversed.value
} else {
null
}
val showAvatar = shouldShowAvatar(item, merged.oldest().nextItem)
val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } }
val itemSeparation: ItemSeparation
val prevItemSeparationLargeGap: Boolean
if (merged is MergedItem.Single || isRevealed.value) {
val prev = listItem.prevItem
itemSeparation = getItemSeparation(item, prev)
val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem
prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item)
} else {
itemSeparation = getItemSeparation(item, null)
prevItemSeparationLargeGap = false
}
ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) {
if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems)
}
if (last != null) {
// no using separate item(){} block in order to have total number of items in LazyColumn match number of merged items
DateSeparator(last.meta.itemTs)
}
if (item.isRcvNew) {
val itemIds = when (merged) {
is MergedItem.Single -> listOf(merged.item.item.id)
is MergedItem.Grouped -> merged.items.map { it.item.id }
}
MarkItemsReadAfterDelay(keyForItem(item), itemIds, finishedInitialComposition, chatInfo.id, listState, markItemsRead)
}
MarkItemsReadAfterDelay(keyForItem(item), itemIds, finishedInitialComposition, chatInfo.id, listState, markItemsRead)
}
}
}
@@ -2256,7 +2399,12 @@ private fun FloatingDate(
if (listState.value.layoutInfo.visibleItemsInfo.lastIndex >= 0) {
val lastVisibleChatItem = lastFullyVisibleIemInListState(topPaddingToContentPx, density, fontSizeSqrtMultiplier, mergedItems, listState)
val timeZone = TimeZone.currentSystemDefault()
lastVisibleChatItem?.meta?.itemTs?.toLocalDateTime(timeZone)?.date?.atStartOfDayIn(timeZone)
val itemTs = lastVisibleChatItem?.meta?.itemTs
if (itemTs != null && itemTs.epochSeconds > 0) {
itemTs.toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone)
} else {
null
}
} else {
null
}
@@ -787,6 +787,7 @@ fun ChatItemView(
is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo)
is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText()
is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText()
is CIContent.ChatBanner -> Spacer(modifier = Modifier.size(0.dp))
is CIContent.InvalidJSON -> {
CIInvalidJSONView(c.json)
DeleteItemMenu()
@@ -192,7 +192,10 @@ fun ChatPreviewView(
is ChatInfo.Group ->
if (cInfo.groupInfo.nextConnectPrepared) {
stringResource(MR.strings.group_preview_open_to_join) to Color.Unspecified
stringResource(
if (cInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business) MR.strings.open_to_connect
else MR.strings.group_preview_open_to_join
) to Color.Unspecified
} else {
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemRejected -> stringResource(MR.strings.group_preview_rejected) to Color.Unspecified
@@ -485,6 +485,17 @@
<string name="group_new_support_chat_one">1 chat with a member</string>
<string name="group_new_support_chats_short">%d chat(s)</string>
<!-- ChatBannerView -->
<string name="chat_banner_connect_to_chat">Tap Connect to chat</string>
<string name="chat_banner_send_request_to_connect">Tap Connect to send request</string>
<string name="chat_banner_accept_contact_request">Accept contact request</string>
<string name="chat_banner_your_contact">Your contact</string>
<string name="chat_banner_join_group">Tap Join group</string>
<string name="chat_banner_your_group">Your group</string>
<string name="chat_banner_group">Group</string>
<string name="chat_banner_business_connection">Business connection</string>
<string name="chat_banner_your_business_contact">Your business contact</string>
<!-- ShareListView.kt -->
<string name="share_message">Share message…</string>
<string name="share_image">Share media…</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22" fill="#1f1f1f"><path d="M515-451.5 460-396q-9 8.5-9 19.75t9 20.25q9 9 20.25 9t20.2-9L604-459.95q8.5-8.37 8.5-20 0-11.64-8.5-20.55l-104-104q-8.5-8.5-19.75-8.5T460-604.25q-9 8.75-9 20t9 20.25l55 55H360q-12.5 0-20.5 8.25t-8 20.75q0 12.5 8 20.5t20.5 8h155ZM480.06-85q-80.97 0-153.13-31.26-72.15-31.27-125.79-85Q147.5-255 116.25-327.02 85-399.05 85-479.94q0-81.97 31.26-154.13 31.27-72.15 85-125.54Q255-813 327.02-844q72.03-31 152.92-31 81.97 0 154.13 31.13 72.17 31.13 125.55 84.5Q813-706 844-633.98q31 72.03 31 153.92 0 80.97-31.01 153.13-31.02 72.15-84.5 125.79Q706-147.5 633.98-116.25 561.95-85 480.06-85Zm-.09-57.5q140.53 0 239.03-98.97 98.5-98.96 98.5-238.5 0-140.53-98.47-239.03-98.46-98.5-239-98.5-139.53 0-238.53 98.47-99 98.46-99 239 0 139.53 98.97 238.53 98.96 99 238.5 99ZM480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 885 B