mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 13:20:36 +00:00
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:
+59
-8
@@ -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
|
||||
|
||||
+59
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+47
-33
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+244
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+43
-10
@@ -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"
|
||||
|
||||
+46
-29
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+6
-6
@@ -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)
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+6
-3
@@ -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 |
Reference in New Issue
Block a user