rfc: bot messages and buttons, core: command markdown, supported commands in profile preferences, chat sessions preference, peer type field in profile to identify bots (#5360)

* rfc: bot messages and buttons

* update

* update bot rfc

* core: add bot commands to chat preferences and peer type to profile

* update postgresql schema

* update query plans

* chat sessions preference

* markdown for bot commands

* schema

* core: file preference, options to create bot from CLI

* core: different command type

* ios: commands menu

* update types

* update ios

* improve command markdown

* core, ios: update types

* android, desktop: clickable commands in messages in chats with bots

* android, desktop: commands menu

* command menu button, bot icon

* ios: connect flow for bots

* android, desktop: connect flow for bots

* icon

* CLI commands to view and set commands, remove "hidden" property of command, bot api docs

* corrections

* fix inheriting profile preferences to business groups

* note on business address

* ios: export localizations

* fix test

* commands to set file preference on user/contact, tidy up layout and display of command and attachment buttons
This commit is contained in:
Evgeny
2025-08-07 11:13:35 +01:00
committed by GitHub
parent 9596029c30
commit 4811d663e6
93 changed files with 2810 additions and 466 deletions
@@ -1633,6 +1633,18 @@ sealed class ChatInfo: SomeChat, NamedChat {
val sendMsgEnabled get() = userCantSendReason == null
val sndReady: Boolean get() =
when(this) {
is Direct -> contact.sndReady
is Group ->
groupInfo.membership.memberActive
&& (groupChatScope != null || (!groupInfo.membership.memberPending && groupInfo.membership.memberRole != GroupMemberRole.Observer))
is Local -> true
is ContactRequest -> false
is ContactConnection -> false
is InvalidJSON -> false
}
fun groupChatScope(): GroupChatScope? = when (this) {
is Group -> groupChatScope?.toChatScope()
else -> null
@@ -1676,6 +1688,20 @@ sealed class ChatInfo: SomeChat, NamedChat {
val hasMentions: Boolean get() = this is Group
val useCommands: Boolean get() = when(this) {
is Direct -> contact.isBot
is Group -> groupInfo.groupProfile.groupPreferences?.commands?.isNotEmpty() ?: false
else -> false
}
val menuCommands: List<ChatBotCommand> get() = when(this) {
is Direct ->
if (contact.isBot) contact.profile.preferences?.commands ?: emptyList()
else emptyList()
is Group -> groupInfo.groupProfile.groupPreferences?.commands ?: emptyList()
else -> emptyList()
}
val contactCard: Boolean
get() = when (this) {
is Direct -> contact.isContactCard
@@ -1759,6 +1785,7 @@ data class Contact(
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
ChatFeature.Reactions -> mergedPreferences.reactions.enabled.forUser
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
ChatFeature.Files -> mergedPreferences.files.enabled.forUser
ChatFeature.Calls -> mergedPreferences.calls.enabled.forUser
}
override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
@@ -1775,7 +1802,6 @@ data class Contact(
return profile.chatViewName.lowercase().contains(s) || profile.displayName.lowercase().contains(s) || profile.fullName.lowercase().contains(s)
}
val directOrUsed: Boolean get() =
if (activeConn != null) {
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
@@ -1783,16 +1809,22 @@ data class Contact(
true
}
val isContactCard: Boolean =
val isContactCard: Boolean get() =
(activeConn == null || activeConn.connStatus == ConnStatus.Prepared) && profile.contactLink != null && active && preparedContact == null && contactRequestId == null
val contactConnIncognito =
val isBot: Boolean get() = profile.peerType == ChatPeerType.Bot
val contactConnIncognito: Boolean get() =
activeConn?.customUserProfileId != null
val chatIconName: ImageResource get() =
if (isBot) MR.images.ic_cube else MR.images.ic_account_circle_filled
fun allowsFeature(feature: ChatFeature): Boolean = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Files -> mergedPreferences.files.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Reactions -> mergedPreferences.reactions.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Calls -> mergedPreferences.calls.contactPreference.allow != FeatureAllowed.NO
}
@@ -1802,6 +1834,7 @@ data class Contact(
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Reactions -> mergedPreferences.reactions.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Files -> mergedPreferences.files.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Calls -> mergedPreferences.calls.userPreference.pref.allow != FeatureAllowed.NO
}
@@ -1931,14 +1964,15 @@ data class Profile(
override val image: String? = null,
override val localAlias : String = "",
val contactLink: String? = null,
val preferences: ChatPreferences? = null
val preferences: ChatPreferences? = null,
val peerType: ChatPeerType? = null
): NamedChat {
val profileViewName: String
get() {
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
}
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences)
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType)
companion object {
val sampleData = Profile(
@@ -1958,11 +1992,12 @@ data class LocalProfile(
override val image: String? = null,
override val localAlias: String,
val contactLink: String? = null,
val preferences: ChatPreferences? = null
val preferences: ChatPreferences? = null,
val peerType: ChatPeerType? = null
): NamedChat {
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences)
fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType)
companion object {
val sampleData = LocalProfile(
@@ -1976,6 +2011,12 @@ data class LocalProfile(
}
}
@Serializable
enum class ChatPeerType {
@SerialName("human") Human,
@SerialName("bot") Bot
}
@Serializable
data class UserProfileUpdateSummary(
val updateSuccesses: Int,
@@ -2030,6 +2071,7 @@ data class GroupInfo (
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
ChatFeature.Reactions -> fullGroupPreferences.reactions.on
ChatFeature.Voice -> fullGroupPreferences.voice.on(membership)
ChatFeature.Files -> fullGroupPreferences.files.on(membership)
ChatFeature.Calls -> false
}
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
@@ -2484,7 +2526,14 @@ class NoteFolder(
override val nextConnectPrepared get() = false
override val profileChangeProhibited get() = false
override val incognito get() = false
override fun featureEnabled(feature: ChatFeature) = feature == ChatFeature.Voice
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> false
ChatFeature.FullDelete -> false
ChatFeature.Reactions -> false
ChatFeature.Voice -> true
ChatFeature.Files -> true
ChatFeature.Calls -> false
}
override val timedMessagesTTL: Int? get() = null
override val displayName get() = generalGetString(MR.strings.note_folder_local_display_name)
override val fullName get() = ""
@@ -4349,6 +4398,7 @@ sealed class Format {
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List<String>): Format()
@Serializable @SerialName("command") class Command(val commandStr: String): Format()
@Serializable @SerialName("mention") class Mention(val memberName: String): Format()
@Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format()
@@ -4363,6 +4413,7 @@ sealed class Format {
is Colored -> SpanStyle(color = this.color.uiColor)
is Uri -> linkStyle
is SimplexLink -> linkStyle
is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace)
is Mention -> SpanStyle(fontWeight = FontWeight.Medium)
is Email -> linkStyle
is Phone -> linkStyle
@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.setNetCfg
import chat.simplex.common.model.ChatModel.changingActiveUserMutex
import chat.simplex.common.model.GroupFeature.Files
import chat.simplex.common.model.MsgContent.MCUnknown
import chat.simplex.common.model.SMPProxyFallback.AllowProtected
import chat.simplex.common.model.SMPProxyMode.Always
@@ -2643,8 +2644,6 @@ object ChatController {
if (cItem.isActiveReport) {
chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id)
}
}
withContext(Dispatchers.Main) {
chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem)
}
} else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) {
@@ -4881,14 +4880,18 @@ data class FullChatPreferences(
val fullDelete: SimpleChatPreference,
val reactions: SimpleChatPreference,
val voice: SimpleChatPreference,
val files: SimpleChatPreference,
val calls: SimpleChatPreference,
val commands: List<ChatBotCommand>
) {
fun toPreferences(): ChatPreferences = ChatPreferences(
timedMessages = timedMessages,
fullDelete = fullDelete,
reactions = reactions,
voice = voice,
calls = calls
files = files,
calls = calls,
commands = commands,
)
companion object {
@@ -4897,7 +4900,9 @@ data class FullChatPreferences(
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
reactions = SimpleChatPreference(allow = FeatureAllowed.YES),
voice = SimpleChatPreference(allow = FeatureAllowed.YES),
files = SimpleChatPreference(allow = FeatureAllowed.YES),
calls = SimpleChatPreference(allow = FeatureAllowed.YES),
commands = listOf(),
)
}
}
@@ -4908,7 +4913,9 @@ data class ChatPreferences(
val fullDelete: SimpleChatPreference?,
val reactions: SimpleChatPreference?,
val voice: SimpleChatPreference?,
val files: SimpleChatPreference?,
val calls: SimpleChatPreference?,
val commands: List<ChatBotCommand>?,
) {
fun setAllowed(feature: ChatFeature, allowed: FeatureAllowed = FeatureAllowed.YES, param: Int? = null): ChatPreferences =
when (feature) {
@@ -4916,6 +4923,7 @@ data class ChatPreferences(
ChatFeature.FullDelete -> this.copy(fullDelete = SimpleChatPreference(allow = allowed))
ChatFeature.Reactions -> this.copy(reactions = SimpleChatPreference(allow = allowed))
ChatFeature.Voice -> this.copy(voice = SimpleChatPreference(allow = allowed))
ChatFeature.Files -> this.copy(files = SimpleChatPreference(allow = allowed))
ChatFeature.Calls -> this.copy(calls = SimpleChatPreference(allow = allowed))
}
@@ -4925,7 +4933,9 @@ data class ChatPreferences(
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
reactions = SimpleChatPreference(allow = FeatureAllowed.YES),
voice = SimpleChatPreference(allow = FeatureAllowed.YES),
files = SimpleChatPreference(allow = FeatureAllowed.YES),
calls = SimpleChatPreference(allow = FeatureAllowed.YES),
commands = null,
)
}
}
@@ -4953,6 +4963,12 @@ data class TimedMessagesPreference(
}
}
@Serializable
sealed class ChatBotCommand {
@Serializable @SerialName("command") class Command(val keyword: String, val label: String, val params: String?): ChatBotCommand()
@Serializable @SerialName("menu") class Menu(val label: String, val commands: List<ChatBotCommand>): ChatBotCommand()
}
@Serializable
data class PresentedServersSummary(
val statsStartedAt: Instant,
@@ -5203,14 +5219,18 @@ data class ContactUserPreferences(
val fullDelete: ContactUserPreference,
val reactions: ContactUserPreference,
val voice: ContactUserPreference,
val files: ContactUserPreference,
val calls: ContactUserPreference,
val commands: List<ChatBotCommand>?,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(
timedMessages = timedMessages.userPreference.pref,
fullDelete = fullDelete.userPreference.pref,
reactions = reactions.userPreference.pref,
voice = voice.userPreference.pref,
calls = calls.userPreference.pref
files = files.userPreference.pref,
calls = calls.userPreference.pref,
commands = commands,
)
companion object {
@@ -5235,11 +5255,17 @@ data class ContactUserPreferences(
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES)
),
files = ContactUserPreference(
enabled = FeatureEnabled(forUser = true, forContact = true),
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES)
),
calls = ContactUserPreference(
enabled = FeatureEnabled(forUser = true, forContact = true),
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES)
),
commands = null,
)
}
}
@@ -5329,6 +5355,7 @@ enum class ChatFeature: Feature {
@SerialName("fullDelete") FullDelete,
@SerialName("reactions") Reactions,
@SerialName("voice") Voice,
@SerialName("files") Files,
@SerialName("calls") Calls;
val asymmetric: Boolean get() = when (this) {
@@ -5348,6 +5375,7 @@ enum class ChatFeature: Feature {
FullDelete -> generalGetString(MR.strings.full_deletion)
Reactions -> generalGetString(MR.strings.message_reactions)
Voice -> generalGetString(MR.strings.voice_messages)
Files -> generalGetString(MR.strings.files_and_media)
Calls -> generalGetString(MR.strings.audio_video_calls)
}
@@ -5357,6 +5385,7 @@ enum class ChatFeature: Feature {
FullDelete -> painterResource(MR.images.ic_delete_forever)
Reactions -> painterResource(MR.images.ic_add_reaction)
Voice -> painterResource(MR.images.ic_keyboard_voice)
Files -> painterResource(MR.images.ic_draft)
Calls -> painterResource(MR.images.ic_call)
}
@@ -5366,6 +5395,7 @@ enum class ChatFeature: Feature {
FullDelete -> painterResource(MR.images.ic_delete_forever_filled)
Reactions -> painterResource(MR.images.ic_add_reaction_filled)
Voice -> painterResource(MR.images.ic_keyboard_voice_filled)
Files -> painterResource(MR.images.ic_draft_filled)
Calls -> painterResource(MR.images.ic_call_filled)
}
@@ -5386,11 +5416,16 @@ enum class ChatFeature: Feature {
FeatureAllowed.YES -> generalGetString(MR.strings.allow_message_reactions_only_if)
FeatureAllowed.NO -> generalGetString(MR.strings.prohibit_message_reactions)
}
Voice -> when (allowed) {
Voice -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(MR.strings.allow_your_contacts_to_send_voice_messages)
FeatureAllowed.YES -> generalGetString(MR.strings.allow_voice_messages_only_if)
FeatureAllowed.NO -> generalGetString(MR.strings.prohibit_sending_voice_messages)
}
Files -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(MR.strings.allow_your_contacts_to_send_files_and_media)
FeatureAllowed.YES -> generalGetString(MR.strings.allow_files_and_media_only_if)
FeatureAllowed.NO -> generalGetString(MR.strings.prohibit_sending_files_and_media)
}
Calls -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(MR.strings.allow_your_contacts_to_call)
FeatureAllowed.YES -> generalGetString(MR.strings.allow_calls_only_if)
@@ -5424,6 +5459,12 @@ enum class ChatFeature: Feature {
enabled.forContact -> generalGetString(MR.strings.only_your_contact_can_send_voice)
else -> generalGetString(MR.strings.voice_prohibited_in_this_chat)
}
Files -> when {
enabled.forUser && enabled.forContact -> generalGetString(MR.strings.both_you_and_your_contact_can_send_files)
enabled.forUser -> generalGetString(MR.strings.only_you_can_send_files)
enabled.forContact -> generalGetString(MR.strings.only_your_contact_can_send_files)
else -> generalGetString(MR.strings.files_prohibited_in_this_chat)
}
Calls -> when {
enabled.forUser && enabled.forContact -> generalGetString(MR.strings.both_you_and_your_contact_can_make_calls)
enabled.forUser -> generalGetString(MR.strings.only_you_can_make_calls)
@@ -5618,7 +5659,9 @@ data class ContactFeaturesAllowed(
val fullDelete: ContactFeatureAllowed,
val reactions: ContactFeatureAllowed,
val voice: ContactFeatureAllowed,
val files: ContactFeatureAllowed,
val calls: ContactFeatureAllowed,
val commands: List<ChatBotCommand>?,
) {
companion object {
val sampleData = ContactFeaturesAllowed(
@@ -5627,7 +5670,9 @@ data class ContactFeaturesAllowed(
fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO),
reactions = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
files = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
calls = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
commands = null,
)
}
}
@@ -5641,7 +5686,9 @@ fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPrefere
fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
reactions = contactUserPrefToFeatureAllowed(contactUserPreferences.reactions),
voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice),
files = contactUserPrefToFeatureAllowed(contactUserPreferences.files),
calls = contactUserPrefToFeatureAllowed(contactUserPreferences.calls),
commands = contactUserPreferences.commands,
)
}
@@ -5661,7 +5708,9 @@ fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed
fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
reactions = contactFeatureAllowedToPref(contactFeaturesAllowed.reactions),
voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice),
files = contactFeatureAllowedToPref(contactFeaturesAllowed.files),
calls = contactFeatureAllowedToPref(contactFeaturesAllowed.calls),
commands = contactFeaturesAllowed.commands,
)
fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): SimpleChatPreference? =
@@ -5697,6 +5746,7 @@ data class FullGroupPreferences(
val simplexLinks: RoleGroupPreference,
val reports: GroupPreference,
val history: GroupPreference,
val commands: List<ChatBotCommand>,
) {
fun toGroupPreferences(): GroupPreferences =
GroupPreferences(
@@ -5709,6 +5759,7 @@ data class FullGroupPreferences(
simplexLinks = simplexLinks,
reports = reports,
history = history,
commands = commands,
)
companion object {
@@ -5722,6 +5773,7 @@ data class FullGroupPreferences(
simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null),
reports = GroupPreference(GroupFeatureEnabled.ON),
history = GroupPreference(GroupFeatureEnabled.ON),
commands = listOf()
)
}
}
@@ -5737,6 +5789,7 @@ data class GroupPreferences(
val simplexLinks: RoleGroupPreference? = null,
val reports: GroupPreference? = null,
val history: GroupPreference? = null,
val commands: List<ChatBotCommand>? = null
) {
companion object {
val sampleData = GroupPreferences(
@@ -5749,6 +5802,7 @@ data class GroupPreferences(
simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null),
reports = GroupPreference(GroupFeatureEnabled.ON),
history = GroupPreference(GroupFeatureEnabled.ON),
commands = null,
)
}
}
@@ -102,19 +102,21 @@ fun ChatView(
val chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }
// They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..."
val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } }
val activeChatInfo = remember { derivedStateOf {
var chatInfo = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo
val activeChat = remember { derivedStateOf {
var chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }
val chatInfo = chat?.chatInfo
if (
chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext
&& chat != null
&& chatInfo is ChatInfo.Group
) {
val scopeInfo = chatsCtx.secondaryContextFilter.groupScopeInfo
chatInfo = chatInfo.copy(groupChatScope = scopeInfo)
chat = chat.copy(chatInfo = chatInfo.copy(groupChatScope = scopeInfo))
}
chatInfo
chat
} }
val user = chatModel.currentUser.value
val chatInfo = activeChatInfo.value
val chatInfo = activeChat.value?.chatInfo
if (chat == null || chatInfo == null || user == null) {
LaunchedEffect(Unit) {
chatModel.chatId.value = null
@@ -138,6 +140,7 @@ fun ChatView(
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val selectedChatItems = rememberSaveable { mutableStateOf(null as Set<Long>?) }
val showCommandsMenu = rememberSaveable { mutableStateOf(false) }
if (appPlatform.isAndroid) {
DisposableEffect(Unit) {
onDispose {
@@ -186,7 +189,6 @@ fun ChatView(
chatsCtx.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0
}
}
val reportsCount = reportsCount(chatInfo.id)
val clipboard = LocalClipboardManager.current
CompositionLocalProvider(
LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false),
@@ -214,7 +216,7 @@ fun ChatView(
ChatLayout(
chatsCtx = chatsCtx,
remoteHostId = remoteHostId,
chatInfo = activeChatInfo,
chat = activeChat,
unreadCount,
composeState,
composeView = { focusRequester ->
@@ -237,7 +239,7 @@ fun ChatView(
)
}
ComposeView(
rhId = remoteHostId.value, chatModel, chatsCtx, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption,
rhId = remoteHostId.value, chatModel, chatsCtx, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, showCommandsMenu, attachmentOption,
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } },
focusRequester = focusRequester
)
@@ -347,7 +349,7 @@ fun ChatView(
ModalManager.end.showCustomModal { close ->
val appBar = remember { mutableStateOf(null as @Composable (BoxScope.() -> Unit)?) }
ModalView(close, appBar = appBar.value) {
val chatInfo = remember { activeChatInfo }.value
val chatInfo = remember { activeChat }.value?.chatInfo
if (chatInfo is ChatInfo.Direct) {
var contactInfo: Pair<ConnectionStats?, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
@@ -377,7 +379,7 @@ fun ChatView(
}
}
LaunchedEffect(Unit) {
snapshotFlow { activeChatInfo.value?.id }
snapshotFlow { activeChat.value?.id }
.drop(1)
.collect {
appBar.value = null
@@ -389,37 +391,37 @@ fun ChatView(
}
},
showReports = {
val info = activeChatInfo.value ?: return@ChatLayout
val cInfo = activeChat.value?.chatInfo ?: return@ChatLayout
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
return@ChatLayout
}
hideKeyboard(view)
scope.launch {
showGroupReportsView(staleChatId, scrollToItemId, info)
showGroupReportsView(staleChatId, scrollToItemId, cInfo)
}
},
showSupportChats = {
val info = activeChatInfo.value ?: return@ChatLayout
val cInfo = activeChat.value?.chatInfo ?: return@ChatLayout
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
return@ChatLayout
}
hideKeyboard(view)
scope.launch {
if (info is ChatInfo.Group && info.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
if (cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
ModalManager.end.showCustomModal { close ->
MemberSupportView(
chatRh,
chat,
info.groupInfo,
cInfo.groupInfo,
scrollToItemId,
close
)
}
} else if (info is ChatInfo.Group) {
} else if (cInfo is ChatInfo.Group) {
val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null)
val supportChatInfo = ChatInfo.Group(info.groupInfo, groupChatScope = scopeInfo)
val supportChatInfo = ChatInfo.Group(cInfo.groupInfo, groupChatScope = scopeInfo)
scope.launch {
showMemberSupportChatView(
chatModel.chatId,
@@ -723,7 +725,8 @@ fun ChatView(
onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(),
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
showSearch = showSearch
showSearch = showSearch,
showCommandsMenu = showCommandsMenu
)
}
}
@@ -768,9 +771,7 @@ private fun connectingText(chatInfo: ChatInfo): String? {
&& !chatInfo.contact.sendMsgToConnect
&& !chatInfo.contact.nextAcceptContactRequest
) {
if (chatInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con) {
generalGetString(MR.strings.contact_should_accept)
} else if (chatInfo.contact.contactGroupMemberId != null) {
if ((chatInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con && !chatInfo.contact.isBot) || chatInfo.contact.contactGroupMemberId != null) {
generalGetString(MR.strings.contact_should_accept)
} else {
generalGetString(MR.strings.contact_connection_pending)
@@ -807,7 +808,7 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType)
fun ChatLayout(
chatsCtx: ChatModel.ChatsContext,
remoteHostId: State<Long?>,
chatInfo: State<ChatInfo?>,
chat: State<Chat?>,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable (FocusRequester?) -> Unit),
@@ -854,8 +855,10 @@ fun ChatLayout(
onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean,
showViaProxy: Boolean,
showSearch: MutableState<Boolean>
showSearch: MutableState<Boolean>,
showCommandsMenu: MutableState<Boolean>
) {
val chatInfo = remember { derivedStateOf { chat.value?.chatInfo } }
val scope = rememberCoroutineScope()
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
Box(
@@ -886,19 +889,20 @@ fun ChatLayout(
val composeViewHeight = remember { mutableStateOf(0.dp) }
Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.secondaryContextFilter == null)) {
val remoteHostId = remember { remoteHostId }.value
val chatInfo = remember { chatInfo }.value
val chat = remember { chat }.value
val chatInfo = chat?.chatInfo
val oneHandUI = remember { appPrefs.oneHandUI.state }
val chatBottomBar = remember { appPrefs.chatBottomBar.state }
val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null }
AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) {
if (chatInfo != null) {
if (chat != null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
// disables scrolling to top of chat item on click inside the bubble
CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec {
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f
}) {
ChatItemsList(
chatsCtx, remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
chatsCtx, remoteHostId, chat, unreadCount, composeState, composeViewHeight, searchValue,
useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
@@ -920,6 +924,11 @@ fun ChatLayout(
)
}
}
if (chatInfo != null && chatInfo.menuCommands.isNotEmpty()) {
Column(Modifier.align(Alignment.BottomStart).padding(bottom = composeViewHeight.value)) {
CommandsMenuView(chatsCtx, chat, composeState, showCommandsMenu)
}
}
}
}
if (chatsCtx.contentTag == MsgContentTag.Report) {
@@ -1362,7 +1371,7 @@ private var reportsListState: LazyListState? = null
fun BoxScope.ChatItemsList(
chatsCtx: ChatModel.ChatsContext,
remoteHostId: Long?,
chatInfo: ChatInfo,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeViewHeight: State<Dp>,
@@ -1399,6 +1408,7 @@ fun BoxScope.ChatItemsList(
developerTools: Boolean,
showViaProxy: Boolean
) {
val chatInfo = chat.chatInfo
val loadingTopItems = remember { mutableStateOf(false) }
val loadingBottomItems = remember { mutableStateOf(false) }
// just for changing local var here based on request
@@ -1561,7 +1571,7 @@ fun BoxScope.ChatItemsList(
highlightedItems.value = setOf()
}
}
ChatItemView(chatsCtx, remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
}
}
@@ -1769,10 +1779,12 @@ fun BoxScope.ChatItemsList(
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)
ConnectionMode.Con -> generalGetString(if (contact.isBot) MR.strings.chat_banner_connect_to_use_bot else MR.strings.chat_banner_send_request_to_connect)
}
} else if (contact.nextAcceptContactRequest) {
generalGetString(MR.strings.chat_banner_accept_contact_request)
} else if (contact.isBot) {
generalGetString(MR.strings.chat_banner_bot)
} else {
generalGetString(MR.strings.chat_banner_your_contact)
}
@@ -3304,7 +3316,7 @@ fun PreviewChatLayout() {
ChatLayout(
chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null),
remoteHostId = remember { mutableStateOf(null) },
chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) },
chat = remember { mutableStateOf(Chat.sampleData) },
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = { _ -> },
@@ -3351,7 +3363,8 @@ fun PreviewChatLayout() {
onComposed = {},
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) }
showSearch = remember { mutableStateOf(false) },
showCommandsMenu = remember { mutableStateOf(false) }
)
}
}
@@ -3383,7 +3396,7 @@ fun PreviewGroupChatLayout() {
ChatLayout(
chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null),
remoteHostId = remember { mutableStateOf(null) },
chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) },
chat = remember { mutableStateOf(Chat.sampleData) },
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = { _ -> },
@@ -3430,7 +3443,8 @@ fun PreviewGroupChatLayout() {
onComposed = {},
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) }
showSearch = remember { mutableStateOf(false) },
showCommandsMenu = remember { mutableStateOf(false) }
)
}
}
@@ -0,0 +1,244 @@
package chat.simplex.common.views.chat
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.common.views.chat.group.*
import chat.simplex.common.views.chat.item.sendCommandMsg
import chat.simplex.common.views.helpers.commandMenuAnimSpec
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.launch
private val COMMAND_MENU_ROW_SIZE = 48.dp
private val MAX_COMMAND_MENU_HEIGHT = COMMAND_MENU_ROW_SIZE * 6 - 8.dp
@Composable
fun CommandsMenuView(
chatsCtx: ChatModel.ChatsContext,
chat: Chat,
composeState: MutableState<ComposeState>,
showCommandsMenu: MutableState<Boolean>
) {
val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() }
val offsetY = remember { Animatable(maxHeightInPx) }
val scope = rememberCoroutineScope()
val currentCommands = remember { mutableStateOf<List<ChatBotCommand>>(emptyList()) }
val menuTreeBackPath = remember { mutableStateOf<List<Pair<String, List<ChatBotCommand>>>>(emptyList()) }
fun filterShownCommands(commands: List<ChatBotCommand>, msg: CharSequence): List<ChatBotCommand> {
val cmds = mutableListOf<ChatBotCommand>()
for (cmd in commands) {
when (cmd) {
is ChatBotCommand.Command ->
if (cmd.keyword.startsWith(msg)) {
cmds.add(cmd)
}
is ChatBotCommand.Menu ->
cmds.addAll(filterShownCommands(cmd.commands, msg))
}
}
return cmds
}
suspend fun closeCommandsMenu() {
showCommandsMenu.value = false
currentCommands.value = emptyList()
menuTreeBackPath.value = emptyList()
if (offsetY.value != 0f) {
return
}
offsetY.animateTo(
targetValue = maxHeightInPx,
animationSpec = commandMenuAnimSpec()
)
}
fun messageChanged(message: String) {
val msg = message.trim()
menuTreeBackPath.value = emptyList()
if (msg == "/") {
currentCommands.value = chat.chatInfo.menuCommands
} else if (msg.startsWith("/")) {
currentCommands.value = filterShownCommands(chat.chatInfo.menuCommands, msg.drop(1))
} else {
scope.launch { closeCommandsMenu() }
}
}
LaunchedEffect(currentCommands.value.isNotEmpty()) {
if (currentCommands.value.isNotEmpty()) {
offsetY.animateTo(
targetValue = 0f,
animationSpec = commandMenuAnimSpec()
)
}
}
LaunchedEffect(composeState.value.message) {
messageChanged(composeState.value.message.text)
}
LaunchedEffect(showCommandsMenu.value) {
if (showCommandsMenu.value) {
currentCommands.value = chat.chatInfo.menuCommands
menuTreeBackPath.value = emptyList()
} else {
closeCommandsMenu()
}
}
@Composable
fun MenuLabelRow(prev: Pair<String, List<ChatBotCommand>>) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(COMMAND_MENU_ROW_SIZE)
.clickable {
if (menuTreeBackPath.value.isNotEmpty()) {
currentCommands.value = menuTreeBackPath.value.last().second
menuTreeBackPath.value = menuTreeBackPath.value.dropLast(1)
}
},
contentAlignment = Alignment.Center
) {
Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) {
Icon(
painterResource(MR.images.ic_arrow_back_ios_new),
contentDescription = null,
tint = MaterialTheme.colors.secondary
)
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
Text(
text = prev.first,
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.weight(1f),
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
fun CommandRow(cmd: ChatBotCommand) {
when (cmd) {
is ChatBotCommand.Command -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(COMMAND_MENU_ROW_SIZE)
.clickable {
if (cmd.params != null) {
val msg = "/${cmd.keyword} ${cmd.params}"
composeState.value = ComposeState(message = ComposeMessage(msg, TextRange(msg.length)), useLinkPreviews = true)
} else {
composeState.value = ComposeState(message = ComposeMessage(), useLinkPreviews = true)
sendCommandMsg(chatsCtx, chat,"/${cmd.keyword}")
}
scope.launch { closeCommandsMenu() }
},
contentAlignment = Alignment.Center
) {
Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) {
Text(
text = cmd.label,
style = MaterialTheme.typography.body1,
maxLines = 1,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Start,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
Text(
text = "/${cmd.keyword}",
style = MaterialTheme.typography.body2,
maxLines = 1,
color = MaterialTheme.colors.secondary
)
}
}
}
is ChatBotCommand.Menu ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(COMMAND_MENU_ROW_SIZE)
.clickable {
menuTreeBackPath.value += Pair(cmd.label, currentCommands.value)
currentCommands.value = cmd.commands
},
contentAlignment = Alignment.Center
) {
Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) {
Text(
text = cmd.label,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.weight(1f),
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
Icon(
painterResource(MR.images.ic_chevron_right),
contentDescription = null,
tint = MaterialTheme.colors.secondary
)
}
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.offset { IntOffset(0, offsetY.value.toInt()) }
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {
scope.launch { closeCommandsMenu() }
},
contentAlignment = Alignment.BottomStart
) {
LazyColumnWithScrollBarNoAppBar(
Modifier
.heightIn(max = MAX_COMMAND_MENU_HEIGHT)
.background(MaterialTheme.colors.surface),
maxHeight = remember { mutableStateOf(MAX_COMMAND_MENU_HEIGHT) },
containerAlignment = Alignment.BottomEnd
) {
itemsIndexed(currentCommands.value, key = { i, cmd -> "$i ${cmd.hashCode()}" }) { i, command ->
if (i == 0) {
val prev = menuTreeBackPath.value.lastOrNull()
if (prev != null) {
Divider()
MenuLabelRow(prev)
}
}
Divider()
Box(Modifier.fillMaxWidth()) { CommandRow(command) }
}
}
}
}
@@ -17,7 +17,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextDecoration
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
@@ -345,6 +347,7 @@ fun ComposeView(
chatsCtx: ChatModel.ChatsContext,
chat: Chat,
composeState: MutableState<ComposeState>,
showCommandsMenu: MutableState<Boolean>,
attachmentOption: MutableState<AttachmentOption?>,
showChooseAttachment: () -> Unit,
focusRequester: FocusRequester?,
@@ -483,7 +486,7 @@ fun ComposeView(
if (!chatItems.isNullOrEmpty()) {
chatItems.forEach { aChatItem ->
withContext(Dispatchers.Main) {
chatsCtx.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem)
chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem)
}
}
return chatItems.first().chatItem
@@ -593,7 +596,6 @@ fun ComposeView(
}
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): List<ChatItem>? {
val cInfo = chat.chatInfo
val cs = composeState.value
var sent: List<ChatItem>?
var lastMessageFailedToSend: ComposeState? = null
@@ -1064,6 +1066,22 @@ fun ComposeView(
val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason)
val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv)
@Composable
fun CommandsButton() {
val commandsEnabled = chat.chatInfo.sendMsgEnabled && chat.chatInfo.menuCommands.isNotEmpty()
IconButton(
onClick = { showCommandsMenu.value = !showCommandsMenu.value },
enabled = commandsEnabled
) {
Box(
modifier = Modifier.size(28.dp).clip(CircleShape),
contentAlignment = Alignment.Center
) {
Text("//", style = MaterialTheme.typography.h3.copy(fontStyle = FontStyle.Italic, color = if (commandsEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary))
}
}
}
@Composable
fun AttachmentButton() {
val isGroupAndProhibitedFiles =
@@ -1087,7 +1105,6 @@ fun ComposeView(
&& !nextSendGrpInv.value
IconButton(
attachmentClicked,
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
enabled = attachmentEnabled
) {
Icon(
@@ -1101,6 +1118,24 @@ fun ComposeView(
}
}
@Composable
fun AttachmentAndCommandsButtons() {
val cInfo = chat.chatInfo
Row(
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
horizontalArrangement = Arrangement.spacedBy((-8).dp)
) {
val msg = composeState.value.message.text.trim()
val showAttachment = cInfo !is ChatInfo.Direct || cInfo.contact.profile.peerType != ChatPeerType.Bot || cInfo.featureEnabled(ChatFeature.Files)
if (cInfo.useCommands && (!showAttachment || msg.isEmpty() || msg.startsWith("/"))) {
CommandsButton()
}
if (showAttachment) {
AttachmentButton()
}
}
}
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
@@ -1280,7 +1315,7 @@ fun ComposeView(
)
Text(
text,
style = MaterialTheme.typography.caption,
style = MaterialTheme.typography.body2,
color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
@@ -1433,7 +1468,7 @@ fun ComposeView(
ContextSendMessageToConnect(generalGetString(MR.strings.compose_send_direct_message_to_connect))
Divider()
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
AttachmentButton()
AttachmentAndCommandsButtons()
SendMsgView_(
disableSendButton = disableSendButton,
sendToConnect = { withApi { sendMemberContactInvitation() } }
@@ -1453,11 +1488,19 @@ fun ComposeView(
connect = { withApi { sendConnectPreparedContact() } }
)
ConnectionMode.Con ->
SendContactRequestView(
disableSendButton = disableSendButton,
icon = MR.images.ic_person_add_filled,
sendRequest = { showSendConnectPreparedContactAlert() }
)
if (chat.chatInfo.contact.isBot) {
ConnectButtonView(
text = stringResource(MR.strings.compose_view_connect),
icon = MR.images.ic_bolt_filled,
connect = { withApi { sendConnectPreparedContact() } }
)
} else {
SendContactRequestView(
disableSendButton = disableSendButton,
icon = MR.images.ic_person_add_filled,
sendRequest = { showSendConnectPreparedContactAlert() }
)
}
}
} else if (
chat.chatInfo is ChatInfo.Direct
@@ -1480,7 +1523,7 @@ fun ComposeView(
)
} else {
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
AttachmentButton()
AttachmentAndCommandsButtons()
SendMsgView_(disableSendButton = disableSendButton)
}
}
@@ -77,7 +77,7 @@ fun SelectedItemsButtonsToolbar(
val forwardCountProhibited = remember { mutableStateOf(false) }
Box {
// It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty
ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() })
ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(false) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() })
Row(
Modifier
.matchParentSize()
@@ -44,7 +44,7 @@ fun SelectedItemsMembersToolbar(
) {
// It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty
Box(Modifier.alpha(0f)) {
ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() })
ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(false) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() })
}
Row(
Modifier
@@ -65,7 +65,7 @@ data class ChatItemReactionMenuItem (
fun ChatItemView(
chatsCtx: ChatModel.ChatsContext,
rhId: Long?,
cInfo: ChatInfo,
chat: Chat,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
imageProvider: (() -> ImageGalleryProvider)? = null,
@@ -109,6 +109,7 @@ fun ChatItemView(
itemSeparation: ItemSeparation,
preview: Boolean = false,
) {
val cInfo = chat.chatInfo
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
@@ -327,7 +328,7 @@ fun ChatItemView(
) {
@Composable
fun framedItemView() {
FramedItemView(chatsCtx, cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem)
FramedItemView(chatsCtx, chat, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem)
}
fun deleteMessageQuestionText(): String {
@@ -1449,7 +1450,7 @@ fun PreviewChatItemView(
ChatItemView(
chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null),
rhId = null,
ChatInfo.Direct.sampleData,
Chat.sampleData,
chatItem,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
@@ -1500,7 +1501,7 @@ fun PreviewChatItemViewDeletedContent() {
ChatItemView(
chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null),
rhId = null,
ChatInfo.Direct.sampleData,
Chat.sampleData,
ChatItem.getDeletedContentSampleData(),
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
@@ -21,14 +21,17 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.ceil
@Composable
fun FramedItemView(
chatsCtx: ChatModel.ChatsContext,
chatInfo: ChatInfo,
chat: Chat,
ci: ChatItem,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
@@ -43,6 +46,7 @@ fun FramedItemView(
scrollToItemId: MutableState<Long?>,
scrollToQuotedItemFromItem: (Long) -> Unit = {},
) {
val chatInfo = chat.chatInfo
val sent = ci.chatDir.sent
val chatTTL = chatInfo.timedMessagesTTL
@@ -182,7 +186,7 @@ fun FramedItemView(
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile)
if (text != "" || ci.meta.isLive) {
CIMarkdownText(ci, chatInfo, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}
@@ -295,7 +299,7 @@ fun FramedItemView(
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}
is MsgContent.MCVideo -> {
@@ -303,26 +307,26 @@ fun FramedItemView(
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile)
if (mc.text != "") {
CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
} else {
ciFileView(ci, mc.text)
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true })
Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) {
CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}
is MsgContent.MCReport -> {
@@ -331,9 +335,9 @@ fun FramedItemView(
append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
}
}
CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix)
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix)
}
else -> CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
else -> CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}
}
@@ -353,8 +357,9 @@ fun FramedItemView(
@Composable
fun CIMarkdownText(
chatsCtx: ChatModel.ChatsContext,
ci: ChatItem,
chatInfo: ChatInfo,
chat: Chat,
chatTTL: Int?,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
@@ -364,9 +369,11 @@ fun CIMarkdownText(
prefix: AnnotatedString? = null
) {
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
val chatInfo = chat.chatInfo
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
MarkdownText(
text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
mentions = ci.mentions, userMemberId = when {
chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId
@@ -377,6 +384,32 @@ fun CIMarkdownText(
}
}
fun sendCommandMsg(chatsCtx: ChatModel.ChatsContext, chat: Chat, msg: String) {
if (chat.chatInfo.sndReady) {
withLongRunningApi(slow = 60_000) {
val cInfo = chat.chatInfo
val chatItems =
chatModel.controller.apiSendMessages(
rh = chat.remoteHostId,
type = cInfo.chatType,
id = cInfo.apiId,
scope = cInfo.groupChatScope(),
composedMessages = listOf(ComposedMessage(fileSource = null, quotedItemId = null, msgContent = MsgContent.MCText(msg), mentions = emptyMap()))
)
if (!chatItems.isNullOrEmpty()) {
chatItems.forEach { aChatItem ->
withContext(Dispatchers.Main) {
chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem)
}
}
}
}
} else {
AlertManager.shared.showAlertMsg(MR.strings.cant_send_message_alert_title, MR.strings.cant_send_commands_alert_text)
}
}
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
const val CHAT_BUBBLE_LAYOUT_ID = "chatBubble"
const val CHAT_COMPOSE_LAYOUT_ID = "chatCompose"
@@ -11,6 +11,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.AnnotatedString.Range
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -61,6 +62,7 @@ fun MarkdownText (
mentions: Map<String, CIMention>? = null,
userMemberId: String? = null,
toggleSecrets: Boolean,
sendCommandMsg: ((String) -> Unit)? = null,
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
@@ -134,7 +136,9 @@ fun MarkdownText (
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
} else {
var hasAnnotations = false
var hasLinks = false
var hasSecrets = false
var hasCommands = false
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
appendSender(this, sender, senderBold)
@@ -143,7 +147,7 @@ fun MarkdownText (
if (ft.format == null) append(ft.text)
else if (toggleSecrets && ft.format is Format.Secret) {
val ftStyle = ft.format.style
hasAnnotations = true
hasSecrets = true
val key = i.toString()
withAnnotation(tag = "SECRET", annotation = key) {
if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) }
@@ -168,10 +172,21 @@ fun MarkdownText (
} else {
append(ft.text)
}
} else if (ft.format is Format.Command) {
if (sendCommandMsg == null) {
append(ft.text)
} else {
hasCommands = true
val ftStyle = ft.format.style
val cmd = ft.format.commandStr
withAnnotation(tag = "COMMAND", annotation = cmd) {
withStyle(ftStyle) { append("/$cmd") }
}
}
} else {
val link = ft.link(linkMode)
if (link != null) {
hasAnnotations = true
hasLinks = true
val ftStyle = ft.format.style
withAnnotation(tag = if (ft.format is Format.SimplexLink) "SIMPLEX_URL" else "URL", annotation = link) {
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
@@ -189,51 +204,53 @@ fun MarkdownText (
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
if (hasAnnotations && uriHandler != null) {
if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) {
val icon = remember { mutableStateOf(PointerIcon.Default) }
ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
if (hasLinks) {
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
}
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
val withAnnotation: (String, (Range<String>) -> Unit) -> Unit = { tag, f ->
annotatedText.getStringAnnotations(tag, start = offset, end = offset).firstOrNull()?.let(f)
}
if (hasLinks && uriHandler != null) {
withAnnotation("URL") { a ->
try {
uriHandler.openUri(annotation.item)
uriHandler.openUri(a.item)
} catch (e: Exception) {
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
}
}
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
uriHandler.openVerifiedSimplexUri(annotation.item)
}
annotatedText.getStringAnnotations(tag = "SECRET", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
val key = annotation.item
withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) }
} else if (hasSecrets) {
withAnnotation("SECRET") { a ->
val key = a.item
showSecrets[key] = !(showSecrets[key] ?: false)
}
} else if (hasCommands && sendCommandMsg != null) {
withAnnotation("COMMAND") { a -> sendCommandMsg("/${a.item}") }
}
},
onHover = { offset ->
icon.value = annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let {
val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) }
icon.value =
if (hasAnnotation("URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) {
PointerIcon.Hand
} ?: annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
.firstOrNull()?.let {
PointerIcon.Hand
} ?: annotatedText.getStringAnnotations(tag = "SECRET", start = offset, end = offset)
.firstOrNull()?.let {
PointerIcon.Hand
} ?: PointerIcon.Default
} else {
PointerIcon.Default
}
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset).any()
annotatedText.hasStringAnnotations(tag = "URL", start = offset, end = offset)
|| annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
}
)
} else {
@@ -176,12 +176,14 @@ fun ChatPreviewView(
is ChatInfo.Direct ->
if (cInfo.contact.isContactCard) {
stringResource(MR.strings.contact_tap_to_connect) to MaterialTheme.colors.primary
} else if (cInfo.contact.isBot && cInfo.contact.nextConnectPrepared) {
stringResource(MR.strings.open_to_use_bot) to Color.Unspecified
} else if (cInfo.contact.sendMsgToConnect) {
stringResource(MR.strings.open_to_connect) to Color.Unspecified
} else if (cInfo.contact.nextAcceptContactRequest) {
stringResource(MR.strings.open_to_accept) to Color.Unspecified
} else if (!cInfo.contact.sndReady && cInfo.contact.activeConn != null && cInfo.contact.active) {
if (cInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con) {
if ((cInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con && !cInfo.contact.isBot) || cInfo.contact.contactGroupMemberId != null) {
stringResource(MR.strings.contact_should_accept) to Color.Unspecified
} else {
stringResource(MR.strings.contact_connection_pending) to Color.Unspecified
@@ -2,14 +2,14 @@ package chat.simplex.common.views.helpers
import androidx.compose.animation.core.*
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 350, easing = FastOutSlowInEasing)
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
fun <T> userPickerAnimSpec() = tween<T>(256, 0, FastOutSlowInEasing)
fun <T> userPickerAnimSpec() = tween<T>(350, 0, FastOutSlowInEasing)
fun <T> mentionPickerAnimSpec() = tween<T>(256, 0, FastOutSlowInEasing)
fun <T> mentionPickerAnimSpec() = tween<T>(350, 0, FastOutSlowInEasing)
fun <T> contextUserPickerAnimSpec() = tween<T>(256, 0, FastOutSlowInEasing)
fun <T> commandMenuAnimSpec() = tween<T>(350, 0, FastOutSlowInEasing)
fun <T> contextUserPickerAnimSpec() = tween<T>(350, 0, FastOutSlowInEasing)
@@ -33,6 +33,7 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme
when (chatInfo) {
is ChatInfo.Group -> chatInfo.groupInfo.chatIconName
is ChatInfo.Local -> MR.images.ic_folder_filled
is ChatInfo.Direct -> chatInfo.contact.chatIconName
else -> MR.images.ic_account_circle_filled
}
ProfileImage(size, chatInfo.image, icon, if (chatInfo is ChatInfo.Local) NoteFolderIconColor else iconColor)
@@ -390,7 +390,7 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close:
ProfileImage(
size = alertProfileImageSize,
image = contact.profile.image,
icon = MR.images.ic_account_circle_filled
icon = contact.chatIconName
)
},
confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat),
@@ -474,7 +474,7 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: ((
ProfileImage(
size = alertProfileImageSize,
image = groupInfo.groupProfile.image,
icon = if (groupInfo.businessChat == null) MR.images.ic_supervised_user_circle_filled else MR.images.ic_work_filled_padded
icon = groupInfo.chatIconName
)
},
confirmText = generalGetString(
@@ -515,7 +515,10 @@ fun showPrepareContactAlert(
ProfileImage(
size = alertProfileImageSize,
image = contactShortLinkData.profile.image,
icon = if (contactShortLinkData.business) MR.images.ic_work_filled_padded else MR.images.ic_account_circle_filled
icon =
if (contactShortLinkData.business) MR.images.ic_work_filled_padded
else if (contactShortLinkData.profile.peerType == ChatPeerType.Bot) MR.images.ic_cube
else MR.images.ic_account_circle_filled
)
},
confirmText = generalGetString(MR.strings.connect_plan_open_new_chat),
@@ -449,6 +449,7 @@
<string name="no_chats_found">No chats found</string>
<string name="contact_tap_to_connect">Tap to Connect</string>
<string name="open_to_connect">Open to connect</string>
<string name="open_to_use_bot">Open to use bot</string>
<string name="open_to_accept">Open to accept</string>
<string name="contact_should_accept">contact should accept…</string>
<string name="connect_with_contact_name_question">Connect with %1$s?</string>
@@ -489,8 +490,10 @@
<!-- 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_connect_to_use_bot">Tap Connect to use bot</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_bot">Bot</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>
@@ -562,6 +565,7 @@
<string name="you_are_observer">you are observer</string>
<string name="reviewed_by_admins">reviewed by admins</string>
<string name="cant_send_message_member_has_old_version">member has old version</string>
<string name="cant_send_commands_alert_text">To send commands you must be connected.</string>
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Image</string>
@@ -2157,6 +2161,9 @@
<string name="allow_your_contacts_to_send_voice_messages">Allow your contacts to send voice messages.</string>
<string name="allow_voice_messages_only_if">Allow voice messages only if your contact allows them.</string>
<string name="prohibit_sending_voice_messages">Prohibit sending voice messages.</string>
<string name="allow_your_contacts_to_send_files_and_media">Allow your contacts to send files and media.</string>
<string name="allow_files_and_media_only_if">Allow files and media only if your contact allows them.</string>
<string name="prohibit_sending_files_and_media">Prohibit sending files and media.</string>
<string name="allow_your_contacts_adding_message_reactions">Allow your contacts adding message reactions.</string>
<string name="allow_message_reactions_only_if">Allow message reactions only if your contact allows them.</string>
<string name="prohibit_message_reactions">Prohibit message reactions.</string>
@@ -2175,6 +2182,10 @@
<string name="only_you_can_send_voice">Only you can send voice messages.</string>
<string name="only_your_contact_can_send_voice">Only your contact can send voice messages.</string>
<string name="voice_prohibited_in_this_chat">Voice messages are prohibited in this chat.</string>
<string name="both_you_and_your_contact_can_send_files">Both you and your contact can send files and media.</string>
<string name="only_you_can_send_files">Only you can send files and media.</string>
<string name="only_your_contact_can_send_files">Only your contact can send files and media.</string>
<string name="files_prohibited_in_this_chat">Files and media are prohibited in this chat.</string>
<string name="both_you_and_your_contact_can_add_message_reactions">Both you and your contact can add message reactions.</string>
<string name="only_you_can_add_message_reactions">Only you can add message reactions.</string>
<string name="only_your_contact_can_add_message_reactions">Only your contact can add message reactions.</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22px" viewBox="0 -960 960 960" width="22px" fill="#000000"><path d="m451.5-89.5-298-172q-14.25-8.43-21.62-21.46-7.38-13.04-7.38-28.54V-649q0-15 7.38-28.29 7.37-13.28 21.62-21.71l298-171.5q13.83-8 28.66-8 14.84 0 28.84 8L807-699q13.75 8.43 21.13 21.71Q835.5-664 835.5-649v337.5q0 15.5-7.37 28.79-7.38 13.28-21.13 21.21l-298 172q-14.33 8-29.16 8-14.84 0-28.34-8Zm0-374V-156l28.5 16.5 29-16.5v-307.66L778-620v-28.5L745.5-667 480-512 214-667l-32 18.16V-619l269.5 155.5Z"/></svg>

After

Width:  |  Height:  |  Size: 522 B